diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..475dfed --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,38 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + # The archive decoders parse untrusted user-uploaded bytes; keep them + # together so a security bump lands as one reviewable change. Groups + # cover version updates only unless applies-to says otherwise, so the + # security updates these exist for need their own entry. + archive-decoders: + patterns: + - github.com/bodgit/* + - github.com/nwaples/rardecode/* + - github.com/dsnet/compress + - github.com/ulikunitz/xz + - github.com/klauspost/compress + archive-decoders-security: + applies-to: security-updates + patterns: + - github.com/bodgit/* + - github.com/nwaples/rardecode/* + - github.com/dsnet/compress + - github.com/ulikunitz/xz + - github.com/klauspost/compress + aws: + patterns: + - github.com/aws/* + otel: + patterns: + - go.opentelemetry.io/* + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36996be..49cef7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,18 +14,29 @@ jobs: os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + # Nothing here pushes back, so the job has no use for the token + # checkout would otherwise leave in .git/config for every later step. + persist-credentials: false + - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v6 with: - stable: false go-version: ${{ matrix.go-version }} - - name: Checkout code - uses: actions/checkout@v2 - - name: Set up dependencies run: go mod download + - name: Run govulncheck + uses: golang/govulncheck-action@v1 + with: + go-version-input: ${{ matrix.go-version }} + # The code is already checked out above; the action's own checkout + # would only redo it and re-persist the credentials. + repo-checkout: false + - name: Run golangci-lint uses: golangci/golangci-lint-action@v8 with: @@ -53,13 +64,16 @@ jobs: test-cache: runs-on: ubuntu-latest steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v6 with: go-version: 1.26.x - - name: Checkout code - uses: actions/checkout@v2 - uses: actions/cache@v4 with: # In order: diff --git a/README.md b/README.md index 22b85a9..705e6e5 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,30 @@ The server management daemon +The daemon communicates with the GameAP panel over gRPC only: it opens an +outbound connection to the panel and keeps a bidirectional stream for tasks, +server statuses, file transfers, console access and metrics. The daemon does +not listen for incoming connections from the panel. + +## Enrollment + +The easiest way to connect a node is the enroll command. It contacts the +panel with a setup key, downloads the TLS certificates and writes a ready +config file: + +```bash +gameap-daemon enroll --connect grpc://panel.example.com:31718/ +``` + +| Flag | Default | Info +|-----------------|------------------------------------------|------------ +| --connect | (required) | Connect URL (grpc://host:port/setupKey) +| --config-path | /etc/gameap-daemon/gameap-daemon.yaml | Path to write the config file +| --certs-dir | /etc/gameap-daemon/certs | Directory to save TLS certificates +| --listen-ip | 0.0.0.0 (auto-detected outbound IP) | Node IP reported to the panel +| --listen-port | 31717 | Node port reported to the panel +| --work-path | /srv/gameap | Working directory for game servers + ## Configuration Configuration file: gameap-daemon.yaml @@ -13,14 +37,26 @@ Configuration file: gameap-daemon.yaml | Parameter | Required | Type | Info |---------------------------|-----------------------|-----------|------------ | ds_id | yes | integer | Dedicated Server ID -| listen_ip | no (default "0.0.0.0")| string | Listen IP -| listen_port | no (default 31717) | integer | Listen port -| api_host | yes | string | API Host -| api_key | yes | string | API Key -| log_level | no | string | Logging level (verbose, debug, info, warning, error, fatal) +| api_key | yes | string | API Key (sent in the gRPC registration) +| api_host | deprecated | string | Fallback source for the gRPC address (host:31718) and insecure transport detection (`http://` prefix). Prefer `grpc.address` / `grpc.insecure` +| log_level | no | string | Logging level (trace, debug, info, warning, error, fatal) + +### gRPC connection + +Either `grpc.address` or the deprecated `api_host` must be set. +| Parameter | Required | Type | Info +|-------------------------------|-----------------------|-----------|------------ +| grpc.address | yes* | string | Panel gRPC endpoint (host:port) +| grpc.insecure | no (default false) | boolean | Disable TLS (plaintext connection) +| grpc.heartbeat_interval | no (default 30s) | duration | Heartbeat period +| grpc.connect_timeout | no (default 30s) | duration | Dial timeout +| grpc.initial_reconnect_delay | no (default 1s) | duration | First reconnect delay +| grpc.max_reconnect_delay | no (default 60s) | duration | Reconnect delay cap -### SSL/TLS +\* If `grpc.address` is empty, the address is derived from `api_host` as host:31718. + +### SSL/TLS (mTLS for the gRPC connection) Certificates can be specified either as file paths or as inline PEM values. If both are set, inline values take precedence over file paths. @@ -33,7 +69,6 @@ If both are set, inline values take precedence over file paths. | certificate_chain_file | yes* | string | Path to Server Certificate file | private_key_file | yes* | string | Path to Server Private Key file | private_key_password | no | string | Server Private Key Password -| dh_file | no | string | Path to Diffie-Hellman file #### Inline PEM values @@ -43,7 +78,9 @@ If both are set, inline values take precedence over file paths. | certificate_chain | yes* | string | Server Certificate PEM | private_key | yes* | string | Server Private Key PEM -\* For each certificate, either the file path or the inline PEM value must be provided. +\* For each certificate, either the file path or the inline PEM value must be +provided. Not required when the connection is insecure (`grpc.insecure: true` +or an `http://` `api_host`). Inline PEM example: ```yaml @@ -64,22 +101,12 @@ private_key: | -----END PRIVATE KEY----- ``` -### Base Authentification - -| Parameter | Required | Type | Info -|---------------------------|-----------------------|-----------|------------ -| password_authentication | no | boolean | Login+password authentification -| daemon_login | no | string | Login. On Linux if empty or not set will be used Linux PAM -| daemon_password | no | string | Password. On Linux if empty or not set will be used Linux PAM - -### Stats +### Metrics filters | Parameter | Required | Type | Info |---------------------------|-----------------------|-----------|------------ | if_list | no | list | Network interfaces to report. Empty/unset = physical, non-loopback interfaces only | drives_list | no | list | Disk mounts to report. Empty/unset = root `/` plus the work_path drive -| stats_update_period | no | integer | Stats update period -| stats_db_update_period | no | integer | Update database period ### Steam @@ -113,5 +140,15 @@ the daemon does not run as `root`. | Parameter | Required | Type | Info |---------------------------|-----------------------|-----------|------------ -| 7zip_path | no | string | Path to 7zip file archiver. Example: "C:\Program Files\7-Zip\7z.exe" -| starter_path | no | string | Path to GameAP Starter. Example: "C:\gameap\gameap-starter.exe" +| path_7zip | no | string | Path to 7zip file archiver. Example: "C:\Program Files\7-Zip\7z.exe" +| path_starter | no | string | Path to GameAP Starter. Example: "C:\gameap\gameap-starter.exe" + +### Removed configuration keys + +The legacy protocols (the inbound binn/TLS listener and the HTTP REST API +client) have been removed, the daemon is gRPC-only now. The following keys +are ignored if present in a config file (unknown keys do not cause errors): + +`listen_ip`, `listen_port`, `daemon_login`, `daemon_password`, +`password_authentication`, `dh_file`, `stats_update_period`, +`stats_db_update_period`, `grpc.enabled`, `task_manager.update_period` diff --git a/config/certs/client.crt b/config/certs/client.crt deleted file mode 100644 index 6dad7e6..0000000 --- a/config/certs/client.crt +++ /dev/null @@ -1,18 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIC2DCCAcACFHR8n/T/52k+3kS6MsZ3uGqMLe7bMA0GCSqGSIb3DQEBCwUAMEUx -CzAJBgNVBAYTAlJVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl -cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjEwNTI2MjA0NTEzWhcNMzEwNTI0MjA0 -NTEzWjAMMQowCAYDVQQDDAEqMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC -AQEAusb7S/XS2KAy0RjdOzbDsDXw3PqB3VwT3lOLZRgf5xLF0FDP9HUxwIvawDfV -iSL8oY9T7osep8aeVMBRIvWFi1FwN1S9HJZZpetTEwlfnVbS6mlyvhqJbbTOhtbp -IfNR9rVV7wm1elnNmtGXfk/bIcdZ7ghhuhjaiLYnxDrD2E1bC2XXXhWkRa8GBnox -fTYcSz1o++AtilW9c05/VmbA9+nWSDmUjCT3PkEYS2OLHJeomq0iV+dvABAfrjQK -coAXk2KsGZYueqv3ar0udHQMmCrHZ5w+iA0zPA+MnV9HfkShxqwmuOemBBHpprhL -f+vrEva9ZqN0DOWth3RvM3e/UQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQC8vpmA -KQgbuxgM1lptzYQfeUJr5aUW3kpxRrPDbxOYNH1Cyt1uYAJtMXsownnJFG5HIwOg -k3H+VpYh3sfUJijKLhWG31kWLr4Cd7PMsziOw/1P9dyKEMXWEz1chvY/JMhLhAVd -olQwoJ0DWXG9qNjQyX7pik2Iuw59gULunCMuV+7uxe9Njxo8Pdm987j1mkMFiCIc -PJCRGGYdvx5Rw+qoYpWy/PFb8SwGAOvyLZ4f9sJEejtWtP8njh6Hm14x3KTBDn+Y -/krDwv6H6GYRZLno7fqnxOuJkIIRRO4JuJ7qYsxbleanV5MNxEEYcjy0L0a4OUNa -w2hvKjeqjavNrMwg ------END CERTIFICATE----- diff --git a/config/certs/client.csr b/config/certs/client.csr deleted file mode 100644 index 45729b0..0000000 --- a/config/certs/client.csr +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIICUTCCATkCAQAwDDEKMAgGA1UEAwwBKjCCASIwDQYJKoZIhvcNAQEBBQADggEP -ADCCAQoCggEBALrG+0v10tigMtEY3Ts2w7A18Nz6gd1cE95Ti2UYH+cSxdBQz/R1 -McCL2sA31Yki/KGPU+6LHqfGnlTAUSL1hYtRcDdUvRyWWaXrUxMJX51W0uppcr4a -iW20zobW6SHzUfa1Ve8JtXpZzZrRl35P2yHHWe4IYboY2oi2J8Q6w9hNWwtl114V -pEWvBgZ6MX02HEs9aPvgLYpVvXNOf1ZmwPfp1kg5lIwk9z5BGEtjixyXqJqtIlfn -bwAQH640CnKAF5NirBmWLnqr92q9LnR0DJgqx2ecPogNMzwPjJ1fR35EocasJrjn -pgQR6aa4S3/r6xL2vWajdAzlrYd0bzN3v1ECAwEAAaAAMA0GCSqGSIb3DQEBCwUA -A4IBAQARP52KABZpnhrsws96hmO/MzrrfEiK1ktXZeBErH6mI1c8slNo9Ky3FwGf -d9YPGazEV5HZBM121mP9OwnE+7Ar9s+id5s7U8tbYhF8AhO0rPvGSFk8DEXQcf2y -dNuv7JPOhtwARFm8STn2UpkmJdZ4+Y8gyeY4MD5SMKiNoIw1zQhijfZ13Gtgh7/V -EjE4mvfGMbQgfJ1rI5xPB2OPXcJiX/VCB1I5Jf/gaG2/x76ck4tTqL31XEe7clNd -4oxvGzbGAMoRCdqUhqd3MXxVTyKmb4S/6MPHXIpDoFFsSyjchsAsm/E5oRh2cqB3 -+9eeDyvSbAs5ZwHtdDde2vp2vJ/J ------END CERTIFICATE REQUEST----- diff --git a/config/certs/client.key b/config/certs/client.key deleted file mode 100644 index 3a62453..0000000 --- a/config/certs/client.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAusb7S/XS2KAy0RjdOzbDsDXw3PqB3VwT3lOLZRgf5xLF0FDP -9HUxwIvawDfViSL8oY9T7osep8aeVMBRIvWFi1FwN1S9HJZZpetTEwlfnVbS6mly -vhqJbbTOhtbpIfNR9rVV7wm1elnNmtGXfk/bIcdZ7ghhuhjaiLYnxDrD2E1bC2XX -XhWkRa8GBnoxfTYcSz1o++AtilW9c05/VmbA9+nWSDmUjCT3PkEYS2OLHJeomq0i -V+dvABAfrjQKcoAXk2KsGZYueqv3ar0udHQMmCrHZ5w+iA0zPA+MnV9HfkShxqwm -uOemBBHpprhLf+vrEva9ZqN0DOWth3RvM3e/UQIDAQABAoIBADypRZKtGzaaCQca -QTfrGHFRg5Hxll3zesx5a8uAo1PkJ/T5WiD2MxtrELffKm4ou84pA8R00JcDDgdO -kst83EucPorp7aWsOx+FJ20GXVR3j3bsmoSdj8beszjd8cfCA/vRd0B0ccl0Ay/m -JIK0ouH8ofuKI1tSmR9PseisgVDteKTm5NHBjam06LhltGOprwsqpQZ5UahZrJOi -VvW8pN/50WihLtP31kbY21q0ZqUITLjPQ3iwqyAs7QRo2nak9zwtgtuFui5bYGSu -rJUTXkC7bwaxTX0q1Z8ZQHB4JI3hYN+aNbIgabbr/bQtTa4tWDTAGiD8LZ+rO2fc -POet5gECgYEA4oXTrEp7QzhH2olBTW5AFI8Aqhx/nv2ypctkSAkMr04z7PbP0qQq -Tlvg6Si5A8/kLDh+W/j1eNj6W5vhiLEjuJskMzSREg53aSb0i2lLAxAPvjB2QMV2 -KlRDHM+6BwAGE0XSctQETcD8Uo5Js9j13a9Edb0/Z8m0qhdpJabcYLECgYEA0xUc -Fozei2WtF1b+mOt3v2SomoltIsDLlsrRqUiR2yXiNAB5gfSZiWaW9CvMOYOhVvcn -A5DqrNxJdOqMu3fVRTSiyoXs7YPwvLBy+cNYoAcFWpDzsOylN6sP/9U9X06CTyHJ -OMd0wZGQ0FrrJfcAjzwG3nhmbxqvd49FZ+IJ8KECgYBwVHLiRlEXxWoEWfoTHrAS -QdPPh7BVtHMJunGxMyOiL0KaqM3oI9aC4xcGKFaPKGF6+EzY6P05f0evc/6EXWtc -WTq7Vb722EuozlOIap9zFlzoMHoDpqfxV7WsLOqQHBfnKMGyhabYs/GsMrkjwVwz -IX3ucPlnS2QW0OMoc7VnEQKBgQDKuKJiMeZloD/Ng5o+iR5XbTfUOxnaX6jnaGBV -oUGbY9XMNYx8t8phQGrHk+yua/GekP0iGqKXY3UU2VJRlTP5JCUYNekm7ylcPmYd -43ORNUz5/u5bw9qlDR/vhv9LMh5KG1uisUQ7xy56PGdL7d3WskKylJVkLQ/J1opE -y7zaIQKBgGV5Fm6HF/ohSM7ufXHIflfiGeDSHQ5/hMsCDTLbjqmCOaoeEv0r9jhe -L38sDjfQG0+DCMD+9CunjaoizH76ZL7RKPkP6ltoX6Vg6V8ixthqfzD4RvuJKvyh -B/7Pn6RaSNj5WjoPxddjiU7jdm1AuvoHPaec9YRdBe1ACvbeHTJG ------END RSA PRIVATE KEY----- diff --git a/config/certs_panel/ca.crt b/config/certs_panel/ca.crt deleted file mode 100644 index 9442611..0000000 --- a/config/certs_panel/ca.crt +++ /dev/null @@ -1,20 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDMDCCAhigAwIBAgIQLNvaUVqRmtXe5i+sShaPezANBgkqhkiG9w0BAQsFADAy -MQswCQYDVQQGEwJSVTEPMA0GA1UECgwGR2FtZUFQMRIwEAYDVQQDDAlHYW1lQVAg -Q0EwHhcNMjExMTA1MTQyNjIwWhcNMzExMTA1MTQyNjIwWjAyMQswCQYDVQQGEwJS -VTEPMA0GA1UECgwGR2FtZUFQMRIwEAYDVQQDDAlHYW1lQVAgQ0EwggEiMA0GCSqG -SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDSPBDd+/vlQqni4+Dil/xgZR8h7CGR+tCg -OSDw+doXZ9bEsvyDjD2wOtQMXzIevK91GPp/ytZTSyn75/HIdlL01SPKux5/FVx0 -c9TmZisgTPO4k1SD7Ih2tnKkFdlqbW8GxpIo+xrEQSqTblWq81MvRS+9gc+RCdlk -JZCahHXjMQBrutOZ/V+J2RPn7ffymBCyg1Q1naTu9bmRaB4yKYRL16JjS4yM3veY -/0+PKFluJLKk/e9WuP+gDEVeob073yaKyDt66gTwdpp3VaXybvktGAiaX+mJnxoX -9Jj894ppszTIyiMgGzcNasEDAPgFeVUbF4hWjyx3yWiJn6JBv2B9AgMBAAGjQjBA -MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFL6GK8ah1qvVtlLosi6AUHqSy1HA -MA4GA1UdDwEB/wQEAwIChDANBgkqhkiG9w0BAQsFAAOCAQEAGut/He+iUM3UncN+ -OQKYp4bTjopdsz6Jg93kuezr02+F/wXXPm48ehChSdiZlRaF5PqseEudohQySAAt -d/UoS9cb/hfFohfw+wpLxbwIHinVyJuYmxocLAp10YAk7YsYCFbqf7n7b7Jut/c2 -97cg37CLlY03jf8k+6MnVJTFo3NFpjwhJxvusUZ3j5UgqehWWBhq7Egd4qFcH7iB -XqoTzmzRtOtBg9jkjhBwytfcjFCjV2hu0ooUcdJRPtdne91O1vj8dVc2J1Z33Yd2 -6dlwFAq7mz81amvUiMufa5N/J3O8K4oKTzi/ofWIFAnsKjDRLFN5LPS4IzH3GDcq -D13p9Q== ------END CERTIFICATE----- diff --git a/config/certs_panel/dh2048.pem b/config/certs_panel/dh2048.pem deleted file mode 100644 index cfe4fce..0000000 --- a/config/certs_panel/dh2048.pem +++ /dev/null @@ -1,8 +0,0 @@ ------BEGIN DH PARAMETERS----- -MIIBCAKCAQEA4kEZdIjJzvRe8H4M0tP5QOdvPVlTBmEOm+C8NGImPYIDIZ3ssAuW -mu+Dre8UVC6OG4Owh8YXgyfeA2NaPHP5CcbnSL75j3phqm9KfM06DSj8/z8hOytd -Am0oa4x0nW7NYad2QKdVu2xtpLeI6713PAQH7z/sgRPb1rjnlqLJYqEFdo2U1eJX -WXgfSWJDvBXhvrko+hSptc66kBOHFHHb6SxnlCJywZXwfD9b8Rq0HDzqPtx/MAPr -9PKVEP2W0aUhFetlz1THhFRomRCHAiCB+qBRrPs/6ek/yKEEW+CMuW9cE0rd4nZP -AWRcjW/GpZ4Y3ugxYu1C0J5uOi7MaNwZEwIBAg== ------END DH PARAMETERS----- diff --git a/config/certs_panel/server.crt b/config/certs_panel/server.crt deleted file mode 100644 index 0e515c9..0000000 --- a/config/certs_panel/server.crt +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDCTCCAfECEERgx1sHpuWCeFChO3e+UDEwDQYJKoZIhvcNAQELBQAwMjELMAkG -A1UEBhMCUlUxDzANBgNVBAoMBkdhbWVBUDESMBAGA1UEAwwJR2FtZUFQIENBMB4X -DTIxMTEwOTE5MTE1NVoXDTMxMTEwOTE5MTE1NVowLzEVMBMGA1UEAwwMOTQ3YWJh -NjM1MjQ5MRYwFAYDVQQKDA1HYW1lQVAgRGFlbW9uMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAzC5b1U05Drlib0hjupR9AsxGR9jrdr5LrWHR/Xf96sms -eyErK8VLibt/cpTayo6Gy+IfxGmL4mq4pQQdjjoS9T5VElkYevl83Tcrlc9E4GhG -I8USRo/Fp/Lk65IRJLZMmkgxJgr4upsidF3Xe1kLP3Ozxm8M58KiCijsV0ODeQwt -5JtqYiCYJWrlTPfN/nbZp9ChFdrE26hC6DyzwTTJ7tIUIOx2w+ps/wx+eonj8eKj -sLxPRz4CXOHHW5afXT5WbbBmXiOno0rHcFI3PteBxUxAKgz8eddG3UjHJZapDgxd -OV+MavAF/b/2pxIvr1Pffk9Or+CiCP0AJ2kDglogXQIDAQABoyMwITAfBgNVHSME -GDAWgBS+hivGodar1bZS6LIugFB6kstRwDANBgkqhkiG9w0BAQsFAAOCAQEABeDl -xUNt7ZVr23/fljsXyX7rSf4obJRHLSbK1v+6ie9YJTlJxGD3l/Zexvw4Y88PYlRX -IeI8WAy19qAyO9WftvjvCE9cg5GJZenPBolg+JUkpTufT4bH/JEOtEexL3KrKsXR -dm4DA8fmi/CkqL1N32vetEP+qeFtHU2VvkalT6+1+OxrTYMclMWMyPln+AH/wZVw -soMWJ0gn1QkH4junQE+gZx0GPfqjYoisPx9v/Qjj4te0w7sW1AwqqY8MGvkVDCNx -SXAti5O+oWKw0/Tj9Ougk9k0oP54njAU14UU8LY79iasRD3d/+BbOypMt73PUBo1 -B2UWxe00qF5RxIyFPg== ------END CERTIFICATE----- diff --git a/config/certs_panel/server.csr b/config/certs_panel/server.csr deleted file mode 100644 index 19d9a59..0000000 --- a/config/certs_panel/server.csr +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIICdDCCAVwCAQAwLzEVMBMGA1UEAwwMOTQ3YWJhNjM1MjQ5MRYwFAYDVQQKDA1H -YW1lQVAgRGFlbW9uMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzC5b -1U05Drlib0hjupR9AsxGR9jrdr5LrWHR/Xf96smseyErK8VLibt/cpTayo6Gy+If -xGmL4mq4pQQdjjoS9T5VElkYevl83Tcrlc9E4GhGI8USRo/Fp/Lk65IRJLZMmkgx -Jgr4upsidF3Xe1kLP3Ozxm8M58KiCijsV0ODeQwt5JtqYiCYJWrlTPfN/nbZp9Ch -FdrE26hC6DyzwTTJ7tIUIOx2w+ps/wx+eonj8eKjsLxPRz4CXOHHW5afXT5WbbBm -XiOno0rHcFI3PteBxUxAKgz8eddG3UjHJZapDgxdOV+MavAF/b/2pxIvr1Pffk9O -r+CiCP0AJ2kDglogXQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAERgW+Ry6DQo -eiwQS9vepCxYoUKwAmdilaLyrv30PdfDscXU+aYuQWwLmGXlqXhmd8YtcI83uKuf -OBqwOsb88/oj5k+EML0vMbDqQ8AGOdHgsGR2l3EhdKgOAjJIcuKREA2ZuFBYuKl+ -hDRMLjFo87Fy1lVrB7Laj4qFDMniiaqg3/rWdMQDRFem0nYsf8byFYxRCDPXH2VM -AK8ejvOocFsMvpbWH6VEcngYlG8Eq+APQH2LrUY/DOkuAyiTIw48xC/GmgwkIpbK -036Sv2ArAbM9GylaJnSVkLRk/lkTavXHXw5JzPRSV7xUmRrAYYqsAQ/urK7kU40W -i3bTNm2yoTU= ------END CERTIFICATE REQUEST----- diff --git a/config/certs_panel/server.key b/config/certs_panel/server.key deleted file mode 100644 index 3389d50..0000000 --- a/config/certs_panel/server.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpQIBAAKCAQEAzC5b1U05Drlib0hjupR9AsxGR9jrdr5LrWHR/Xf96smseyEr -K8VLibt/cpTayo6Gy+IfxGmL4mq4pQQdjjoS9T5VElkYevl83Tcrlc9E4GhGI8US -Ro/Fp/Lk65IRJLZMmkgxJgr4upsidF3Xe1kLP3Ozxm8M58KiCijsV0ODeQwt5Jtq -YiCYJWrlTPfN/nbZp9ChFdrE26hC6DyzwTTJ7tIUIOx2w+ps/wx+eonj8eKjsLxP -Rz4CXOHHW5afXT5WbbBmXiOno0rHcFI3PteBxUxAKgz8eddG3UjHJZapDgxdOV+M -avAF/b/2pxIvr1Pffk9Or+CiCP0AJ2kDglogXQIDAQABAoIBAQC3VQTWEGGkC+cL -nscVN9DLm1mbl3VNCG7w/j1LxtryXyUE5fJaeetqfzVfT7LoX3M/TAlUFaUppsOm -P3y+QPzWwaInOGKXwL5R7wCuog3pJReddI0FWICUXa8Yqe3Etf8wJJQk4YMWIsec -Dsq1kW1dCumnyfyfeI6AauBTNmatXxyINv3sbPOXAZooPTZQwBILvAUBlrl3/7SH -K8OGzUj+Ay/9bYxB9OKEpUI7XqnHWsdkW0scFmi6atB7sDCn4XPiriehLhNDt4ps -znn7OTi6ZMWC2GT6q4u245fkznJACnE+L6qST0RrCm6gkgh8wCEtqogBm+ZN8wwM -AjC+mzCNAoGBAOmMM4gWvZsJvV2XwzYSRhq9IsOQUJXKh8LWmm3WfMKwlxtYjrh5 -sd/0UL33H0UU50z+sVh2B4LcdysNWS9XR7ghzMSB+R3DhKZqt10E4KaDbSqzYeGP -Hz8a7qnbnayqOmSIvbUAQM7VOa8G6m1l7WZrfNPpLk2hD2yS+y/w22BzAoGBAN/P -bA4vGLN0sNgrXYmATGtsqRXZHv5pwLcHNp+ceARo4G06PGK/EWiBPnO0PvfG3/ri -QEj8dUQ/8OQM6Uk8FhuOqdpkZXDzliklnriL/Ywws9hzYCKgIhQhPweZtJn9fJ6V -+kTdUGvRlifoICrOanH3QUBPc8RNtxv9t3ZsLFfvAoGBANZRh5SSMcsNpA44T7bW -DClLeZR3YcwkAHPHFg5Hr1PGU9SPbuFRI+89t76sAbEWXrAkZcClB32KLX1/kWFF -OhZ6Rfvqvh4XJyrEcaJV8IY9wzdVSDSCb40sfOm5FLe3u0A4z8ZDBjYILBg3Q3fC -+plreDDLImewroPXWdUDtX2LAoGBAKaRUgMwa/rLQv5vL9sw4C8nHKRFBjska6gM -N0wNAImPoE/KgryfJQ6Y+ejU4fQ4T+QBVQS8122nBNpE/a5iiLWTtLfz9kddEomn -FPyWO2qFqKPUIl1Cvnoq0CUQ8QeWT5QbXLq516uWvWEP8jjJjREqiqaZOziC+l9b -sZnyIgKDAoGAMgy3WGowjR+ZNBvaosWUSQy7UfiEj5KD5OoV0G46SqWFeRdIceaN -BBdNe2qA/A2IbUvYRKGVL7jM21NJQRF16vqul+aI2PAsfm1cj5ioclD5oSeNNiVJ -hGsCmyjWusf7C7965OJHf8j86454xGpLzC1Mk1YXsoXJULBNONfswoM= ------END RSA PRIVATE KEY----- diff --git a/config/gameap-daemon.yaml b/config/gameap-daemon.yaml index 6606c40..74ee3b3 100644 --- a/config/gameap-daemon.yaml +++ b/config/gameap-daemon.yaml @@ -1,4 +1,8 @@ # GameAP Daemon configuration +# +# The daemon communicates with the GameAP panel over gRPC only. The +# easiest way to produce a working config is the enroll command: +# gameap-daemon enroll --connect grpc://panel.example.com:31718/ # ------------------------------------------------------------------ # Node @@ -7,39 +11,41 @@ # Dedicated server ID (required, must match the node ID in GameAP panel) ds_id: 1 -# ------------------------------------------------------------------ -# Listener -# ------------------------------------------------------------------ - -listen_ip: 0.0.0.0 -# listen_port: 31717 +# API key issued by the panel (required, sent in the gRPC registration) +api_key: your-api-key-here # ------------------------------------------------------------------ -# GameAP API +# gRPC connection to the panel # ------------------------------------------------------------------ -api_host: http://localhost:2080 -api_key: KxL6shP6Q4aEJeLXIaoe8mXpkIrTTFSQMKWt1qAfBZ2TQe42YvzDzrlwVZ2F5pgH +grpc: + # Panel gRPC endpoint (host:port). If not set, the address is derived + # from the deprecated api_host key (host:31718). + address: localhost:31718 -# ------------------------------------------------------------------ -# Authentication -# ------------------------------------------------------------------ + # Disable TLS (plaintext connection). Also enabled when the deprecated + # api_host starts with http:// + # insecure: false -daemon_login: sEcreT-L0gin -daemon_password: seCrEt-PaSSW0rD + # heartbeat_interval: 30s + # connect_timeout: 30s + # initial_reconnect_delay: 1s + # max_reconnect_delay: 60s -# Enable login/password authentication for the daemon RPC (default: false). -# password_authentication: false +# Deprecated: kept only as a fallback source for the gRPC address and +# the insecure transport detection. Prefer grpc.address / grpc.insecure. +# api_host: http://localhost:2080 # ------------------------------------------------------------------ -# TLS / Certificates +# TLS / Certificates (mTLS for the gRPC connection) # Relative paths are resolved against the config file directory. +# The enroll command downloads these automatically. # ------------------------------------------------------------------ # Certificates can be specified as file paths: -ca_certificate_file: ./certs_panel/ca.crt -certificate_chain_file: ./certs_panel/server.crt -private_key_file: ./certs_panel/server.key +ca_certificate_file: ./certs/ca.crt +certificate_chain_file: ./certs/server.crt +private_key_file: ./certs/server.key # Or as inline PEM values (takes precedence over file paths): # ca_certificate: | @@ -61,15 +67,14 @@ private_key_file: ./certs_panel/server.key # If your private key is encrypted, you can specify the password here: # private_key_password: abracadabra -dh_file: /etc/gameap-daemon/certs_panel/dh2048.pem - # ------------------------------------------------------------------ -# Statistics +# Metrics filters +# Restrict which network interfaces and disks are reported by the +# metrics collector. Leave empty (or remove) to auto-select physical, +# non-loopback interfaces and the root "/" filesystem plus the +# work_path drive. # ------------------------------------------------------------------ -# Network interfaces and disk mounts reported in node metrics. -# Leave empty (or remove) to auto-select physical, non-loopback interfaces -# and the root "/" filesystem plus the work_path drive. if_list: - eth0 - eth1 @@ -78,9 +83,6 @@ drives_list: - / - /home/server -stats_update_period: 60 -stats_db_update_period: 300 - # ------------------------------------------------------------------ # Logging # log_level is parsed by the first character: trace / debug / info / @@ -101,8 +103,9 @@ log_level: debug # Directory for additional tools (added to PATH). Defaults to {work_path}/tools. # tools_path: /srv/gameap/tools -# Path to the steamcmd executable used for game installations. -# steamcmd_path: /srv/gameap/steamcmd/steamcmd.sh +# Directory that contains steamcmd (steamcmd.sh / steamcmd.exe), used for +# game installations. The enroll command writes it automatically. +# steamcmd_path: /srv/gameap/steamcmd # Windows-only paths: # path_7zip: C:\gameap\tools\7zip\7za.exe @@ -116,14 +119,45 @@ log_level: debug # login: your-steam-user # password: your-steam-pass +# ------------------------------------------------------------------ +# Remote repository replacements +# Replace the domain of game/mod remote repository URLs with mirrors. +# Key is a host (optionally host:port) from the original URL; a key with +# a port matches "host:port" exactly, a key without a port matches the +# hostname regardless of the port. +# Replacement value is "[scheme://]host[:port][/path-prefix]" — parts that +# are not set are kept from the original URL. Query string is preserved. +# Mirrors are tried from the highest priority to the lowest (a plain +# string means priority 0; equal priorities keep the config order). +# The original URL is always tried last, after all replacements failed. +# +# Example: with the rules below and the original URL +# http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz +# the daemon tries +# 1. http://cdn.gameap.com/cstrike-1.6/hlcs_base.tar.xz +# 2. https://mirror.gameap.ru/files/cstrike-1.6/hlcs_base.tar.xz +# 3. http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz (original) +# ------------------------------------------------------------------ + +# remote_repository_replacements: +# files.gameap.ru: +# - replace: cdn.gameap.com +# priority: 10 +# - replace: https://mirror.gameap.ru/files +# priority: 9 +# +# # Short forms: +# files.example.com: cdn.example.com # single replacement +# files.example.org: # list order = priority +# - cdn1.example.org +# - cdn2.example.org + # ------------------------------------------------------------------ # Task manager # ------------------------------------------------------------------ # task_manager: -# update_period: 1s # how often to poll the API for new tasks -# run_task_period: 10ms # delay between dequeued task executions -# workers_count: 4 +# task_timeout: 2h # per-task execution timeout # ------------------------------------------------------------------ # Metrics diff --git a/go.mod b/go.mod index 54fbd3d..1fe1a71 100644 --- a/go.mod +++ b/go.mod @@ -1,128 +1,137 @@ module github.com/gameap/daemon -go 1.26 +// 1.26.5 is a security floor, not a language requirement: it carries the fixes +// for GO-2026-4970 and GO-2026-4864 (os.Root escapes) and GO-2026-4869 +// (unbounded allocation in archive/tar). The daemon resolves every +// caller-supplied path through os.Root, so those are load-bearing. +go 1.26.5 require ( + github.com/bodgit/sevenzip v1.6.5 github.com/containerd/errdefs v1.0.0 - github.com/dgraph-io/ristretto/v2 v2.4.0 + github.com/dgraph-io/ristretto/v2 v2.4.2 + github.com/dsnet/compress v0.0.1 github.com/emirpasic/gods v1.18.1 - github.com/et-nik/binngo v0.3.0 - github.com/gabriel-vasile/mimetype v1.4.13 - github.com/gameap/gameap v0.0.0-20260514173044-47673e35049a - github.com/gameap/gameapctl v0.25.0 - github.com/go-resty/resty/v2 v2.17.2 + github.com/gameap/gameap v0.0.0-20260729224522-5d77d14698ba + github.com/gameap/gameapctl v0.30.1 github.com/goccy/go-yaml v1.19.2 github.com/google/uuid v1.6.0 github.com/gopherclass/go-shellquote v0.0.0-20200814145606-fab22d094485 - github.com/gorilla/mux v1.8.1 github.com/hashicorp/go-getter v1.8.6 - github.com/moby/moby/api v1.54.1 - github.com/moby/moby/client v0.4.0 + github.com/klauspost/compress v1.19.1 + github.com/moby/moby/api v1.55.0 + github.com/moby/moby/client v0.5.1 + github.com/nwaples/rardecode/v2 v2.3.0 github.com/pkg/errors v0.9.1 - github.com/samber/lo v1.53.0 - github.com/shirou/gopsutil/v3 v3.24.5 + github.com/rs/xid v1.6.0 + github.com/shirou/gopsutil/v4 v4.26.6 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 + github.com/ulikunitz/xz v0.5.16 github.com/urfave/cli/v2 v2.27.7 - github.com/viney-shih/go-lock v1.1.2 go.uber.org/mock v0.6.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 - google.golang.org/grpc v1.80.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 + google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.11 ) require ( - cel.dev/expr v0.25.1 // indirect + cel.dev/expr v0.25.2 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.22.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.5.3 // indirect - cloud.google.com/go/monitoring v1.24.3 // indirect - cloud.google.com/go/storage v1.61.3 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + cloud.google.com/go/iam v1.12.0 // indirect + cloud.google.com/go/monitoring v1.30.0 // indirect + cloud.google.com/go/storage v1.64.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect - github.com/aws/smithy-go v1.24.2 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.43.3 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.34 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.33 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.27 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.35 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.106.3 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 // indirect + github.com/aws/smithy-go v1.27.6 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect + github.com/bodgit/plumbing v1.3.0 // indirect + github.com/bodgit/windows v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/cstockton/go-conv v1.0.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-connections v0.8.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.17.0 // indirect - github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect + github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-version v1.8.0 // indirect - github.com/klauspost/compress v1.18.5 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/rs/xid v1.6.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect - github.com/ulikunitz/xz v0.5.15 // indirect - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.2.2 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect + github.com/stangelandcl/ppmd v0.1.1 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/sdk v1.42.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/net v0.53.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go4.org v0.0.0-20260112195520-a5071408f32f // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.271.0 // indirect - google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/api v0.291.0 // indirect + google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8ede932..89f63e6 100644 --- a/go.sum +++ b/go.sum @@ -1,138 +1,142 @@ -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= +cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= -cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= -cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= -cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= -cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= -cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= -cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= -cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= -cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= -cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= +cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= +cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q= +cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= +cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= +cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg= +cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= +cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 h1:c/Ivw7FuawPLfrr+zB0LZKeCchO2cAHQpF2qZ6OV7rQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0/go.mod h1:Zba7lknY/d78oxbKqFTmCsaGwfpzeJ3ktrrLXtnTV6g= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0 h1:xTXsqDOj5k9mK3VVWHYUryryJCIdYfXxdjKFwpzINUw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 h1:18FRm6ZcN/x9+ZmhMr96hLcTtlLn2/gHPuDLVeg7XcY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= -github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= -github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= -github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.43.3 h1:XJIcfv8uDs2ukdQsoAC8/Ebu1ejxwzlayl2ZsiFns2A= +github.com/aws/aws-sdk-go-v2 v1.43.3/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I= +github.com/aws/aws-sdk-go-v2/config v1.32.34 h1:o+YAizrX562nEZXaB38uYTK8RvIsvW0uuRP+e5e0Pfk= +github.com/aws/aws-sdk-go-v2/config v1.32.34/go.mod h1:wc0zYRChOniiufvdWiRVf3jgXSgbkvaD683IHHHc2ZQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.33 h1:/e5V3EWfeDiW6cuRxHsC8gbwko4/vvVYPJR2afBKFFY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.33/go.mod h1:ZxAmkcyOM9beY/WO9oxp2oVPXiP3rq5N1/p4NbenJdE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34 h1:1EsGke6rTD2CG3j2MMVB77n6Q+FlbQWYI/dFdLWBNtM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.34/go.mod h1:5B1Z/QbaWzqoWRzYxZfmCbDDRcvUHcfAIQw/S+KfDmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 h1:vuIfjzoeqhQMGJyOBU3t0ZEjn2jrN8Bbg1N4CgjzM5Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34/go.mod h1:hP28cN4CPJLZHirdQPrZR50JcLN4ApRJP2tzG8cRlhY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 h1:9faHsnqxJ1vDvB4wMZy/ajIDyz5QhllQjjc72RJpXAw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34/go.mod h1:Yp6nIyejpa23nzlB/LhT63KTla9Jdi06nv/HH/OkAH8= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35 h1:Oe8gMKJLO5awqpa5EhAGKVnBv1s+brdWVuxM2mDa7zA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.35/go.mod h1:FZevcG9cOST/FWAAUhHIchjR9fXFXFRCWodOhx+PDLA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.27 h1:zwB6ltUc0UiyOsRQaMQ8jNLjKECbjhadCyl4hqV0y/c= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.27/go.mod h1:ce9y+Y+hGLUyPKJZZJGoFLuFJNfCNuWZTujUJAsckQA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34 h1:sYg4qHWLqsjp15PzX7XCOHSOgKEGoZ5vQY43VvZ1pas= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.34/go.mod h1:N58SSz3roKf1HzW5qRaOiyk6MbDLTKgLPvlTfJ90iyI= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.35 h1:ohfdSAm4TA6nryIY7mLqe4mnSIAnAreoAPBM81ZVoIM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.35/go.mod h1:uUjphnxMb3HH3vIiOHl4dH0fGNKL+csjqRQEabbfw5k= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.3 h1:oSfubHEP3a0nTRAtm99IDaws0f15qwf+fOwS1Esh5jI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.3/go.mod h1:lWk6L5Q3YkaC7so1bQUJkvF7hj2KUFzdZ4w15wc2GHY= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.3 h1:togAtAmgV5IGMnQDuBDJeM8z5Y5RN6G7xeOgphWz+Yc= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.3/go.mod h1:T7xKUUUvN7W3RW8UmMvKnD12xqh+Ux2gCPHPhnt64Dg= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.3 h1:YjH64OUytnWZBHUtM9GMyi4ZWBiSQdEJkZuPykOIe44= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.3/go.mod h1:5qoHcDZDTSJotoKk1bvVRPv1MXaL/NhfY9ng8D1g/ig= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3 h1:A4o1di/XGaqtw6r3toSBrFX2U7mVSLqg7jo9wL4I+cU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.3/go.mod h1:sKuKz2kHtrGVtFu34vbM3LWSA9CKD9YZUmm6e5PPqRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.3 h1:Fi7+DiKN1+QphlajvE6FqeZ8GRbnnRul7zTdUiRpbGc= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.3/go.mod h1:KCc3e27fHZUGtzpek7wZcp6dyCpGkJJo/+3PBujh/yU= +github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= +github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= +github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= +github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng= +github.com/bodgit/sevenzip v1.6.5/go.mod h1:GhuB6Lq1xCpP1sps+horjZ8lgiKPJcy2zUX3prla9wc= +github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= +github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/cstockton/go-conv v1.0.0 h1:zj/q/0MpQ/97XfiC9glWiohO8lhgR4TTnHYZifLTv6I= -github.com/cstockton/go-conv v1.0.0/go.mod h1:HuiHkkRgOA0IoBNPC7ysG7kNpjDYlgM7Kj62yQPxjy4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU= -github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= +github.com/dgraph-io/ristretto/v2 v2.4.2 h1:x0cvjmUKxt764Yxdk2nr94we1AvPPAMh1rh5TQ+Jo80= +github.com/dgraph-io/ristretto/v2 v2.4.2/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= +github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= +github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= +github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= -github.com/et-nik/binngo v0.3.0 h1:DSJtcd+E5FQrYgtARTMK3P+TMvgFFR1LQ6RtoSMeJI8= -github.com/et-nik/binngo v0.3.0/go.mod h1:C6qU/nWfykKu9b3t67c80XCQ+95S9+v1rEKWkgUUT8w= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gameap/gameap v0.0.0-20260514173044-47673e35049a h1:SsP+vI+em2j3v/FQzXODK362BVIBveJfVfPnuK5Bk/I= -github.com/gameap/gameap v0.0.0-20260514173044-47673e35049a/go.mod h1:9WvVGHRva598VzZuI51HVV4U454dii7O6M9/FoiaJRU= -github.com/gameap/gameapctl v0.25.0 h1:ECxwkcEDY7XFo1K06O5YVgTAbZhpMEWOtoGf5T9dbKM= -github.com/gameap/gameapctl v0.25.0/go.mod h1:vljtrlaxhRzKhvIxg/W9/mfOzGXaXXeoDPaXrH7E8ts= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/gameap/gameap v0.0.0-20260729224522-5d77d14698ba h1:k0YCLSokN2HoPl0oykGuCpr0NHfgZ1Z9fCha3cLbafA= +github.com/gameap/gameap v0.0.0-20260729224522-5d77d14698ba/go.mod h1:rjxnoVnZz9li4bqIuZMYVgxOEAa7vBPOatslu1FMoW4= +github.com/gameap/gameapctl v0.30.1 h1:l8Txr1u+RLBVJvceZSucdcriUvcd8IYUbURTlQLB/+8= +github.com/gameap/gameapctl v0.30.1/go.mod h1:GEUPbmHDrnFYxVUqN3FpE7afnrEQkjtdrlK96ENeHkE= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= -github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= @@ -141,42 +145,48 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= -github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4= +github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/gopherclass/go-shellquote v0.0.0-20200814145606-fab22d094485 h1:1bPYu0COEL28IzKZwQN+uQIGuX31YFr3r+m3TU6hqh8= github.com/gopherclass/go-shellquote v0.0.0-20200814145606-fab22d094485/go.mod h1:aSawckur6IQmYgHum1MMf1oU1g1uT1Hz2e+cMvrs2Ww= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72 h1:vTCWu1wbdYo7PEZFem/rlr01+Un+wwVmI7wiegFdRLk= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72/go.mod h1:Vn+BBgKQHVQYdVQ4NZDICE1Brb+JfaONyDHr3q07oQc= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74 h1:mymLUKThnV9wFvogOK8NnsMP9/vlhnjXY98gr2QIGW8= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74/go.mod h1:Bh9qYL8ehmDxSg14Tk8oxFCP90XHfs6NxV1D84884xA= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-getter v1.8.6 h1:9sQboWULaydVphxc4S64oAI4YqpuCk7nPmvbk131ebY= github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo= -github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= -github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+/NiwzJtPYXXKRcyjmEUhsDci6YK3c= +github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= -github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= -github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= +github.com/nwaples/rardecode/v2 v2.3.0 h1:CtgyxWm8ClLcSh1u4M58fOz6lmeb/j4V7KpaEi/6UtM= +github.com/nwaples/rardecode/v2 v2.3.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -184,105 +194,115 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= -github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shoenig/go-m1cpu v0.2.2 h1:4nc55oVv7nygGnfI9bhLCLzUEs4794y0Bkqx4q2zy7Y= +github.com/shoenig/go-m1cpu v0.2.2/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= -github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8= +github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= -github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= +github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= +github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= -github.com/viney-shih/go-lock v1.1.2 h1:3TdGTiHZCPqBdTvFbQZQN/TRZzKF3KWw2rFEyKz3YqA= -github.com/viney-shih/go-lock v1.1.2/go.mod h1:Yijm78Ljteb3kRiJrbLAxVntkUukGu5uzSxq/xV7OO8= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= +go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= -google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= -google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/api v0.291.0 h1:wfPbbY+mr9c7wZLqqzrHJLft/q8iFKREd6IgTBUene0= +google.golang.org/api v0.291.0/go.mod h1:at7kwWbuonglBFEBoeMDAV1bguHqL3qf0BHFsv3coa0= +google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0 h1:xJf8e9ReUqiexuIT3OWJkvA4RdgR8K4Hfheq8gaKxIM= +google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:0MNk3ibJAyOwDZVlp18knQe3jRyFxpcU06xjxhgjx0M= +google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 h1:ybvH/ZpOcpCrjtkb7oW/fdlzbEmRVeumw19SRQmNFKU= +google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:HJ9MpJLeDSstBkx1LILTpd5f41ADSMZcTPypw02qEGw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/archive/archiver.go b/internal/app/archive/archiver.go new file mode 100644 index 0000000..991d1b0 --- /dev/null +++ b/internal/app/archive/archiver.go @@ -0,0 +1,134 @@ +// Package archive creates and extracts archives (zip, tar family, single-file +// compression, 7z, rar) on behalf of API requests. Every caller-supplied path +// is resolved with fsutil.RootRel and accessed through an *os.Root opened at +// the work directory, so no operation can escape the work directory — os.Root +// refuses symlink and ".." resolution outside of it. Extracted archive entries +// are additionally rejected lexically when their path is absolute or starts +// with ".." (zip-slip), because the API contract requires the friendly error. +// +// Extraction feeds attacker-controlled bytes to third-party format decoders, +// so the caller is expected to run it under a recover (see the gRPC archive +// handler). One residual risk cannot be handled here: bodgit/sevenzip sizes +// several slices directly from 7z header counts and validates them only +// against MaxUint32, so a crafted header can request an allocation large +// enough for the Go runtime to abort the process — a throw that no recover +// can intercept. It exposes no option to bound this; the entry-count check +// below runs only after its reader is already constructed. +package archive + +import ( + "math" + + "github.com/pkg/errors" + + "github.com/gameap/daemon/internal/app/osowner" + pb "github.com/gameap/gameap/pkg/proto" +) + +const ( + // defaultMaxTotalBytes caps the uncompressed payload of one operation when + // the request does not set a limit (decompression-bomb protection). + defaultMaxTotalBytes uint64 = 10 << 30 // 10 GiB + // defaultMaxFiles caps the number of entries of one operation when the + // request does not set a limit. + defaultMaxFiles uint32 = 100_000 + + // maxFollowDepth bounds symlink-following recursion during create. It is a + // backstop only: os.Root refuses to resolve a path crossing more than + // rootMaxSymlinks (8) links, so in practice that limit fires first. + maxFollowDepth = 40 +) + +// ErrArchiveEncrypted reports an archive the daemon cannot open because it is +// password protected. The API distinguishes this from a corrupt archive to +// prompt the user for a password. +var ErrArchiveEncrypted = errors.New("archive is encrypted, password required") + +// Result summarizes one Create or Extract call. +type Result struct { + FilesProcessed int64 + BytesProcessed int64 // uncompressed bytes + ArchiveSize int64 // produced archive when creating, source archive when extracting + Skipped []string + // Format the operation actually used, which differs from the requested one + // when the request left it unspecified and the daemon resolved it. + Format pb.ArchiveFormat +} + +// ProgressFunc is invoked after each processed entry (it may be nil). +// Totals are intentionally not reported: the proto allows 0 = unknown. +type ProgressFunc func(filesProcessed, bytesProcessed int64, currentEntry string) + +// accumulator tracks running counters, enforces the limits and reports +// progress for both create and extract. +type accumulator struct { + files int64 + bytes int64 + maxBytes uint64 + maxFiles uint64 + progress ProgressFunc +} + +func newAccumulator(maxBytes uint64, maxFiles uint32, progress ProgressFunc) *accumulator { + if maxBytes == 0 { + maxBytes = defaultMaxTotalBytes + } + // maxBytes arrives as uint64 while the counters run on int64; clamp so the + // conversions in bytesLeft and addEntry cannot wrap negative. The clamp + // stops one below MaxInt64 because every streaming copy reads bytesLeft()+1 + // bytes: at MaxInt64 that sum overflows into a negative limit, which + // io.LimitReader reports as immediate EOF and would silently reduce every + // entry to nothing. + if maxBytes > math.MaxInt64-1 { + maxBytes = math.MaxInt64 - 1 + } + if maxFiles == 0 { + maxFiles = defaultMaxFiles + } + + return &accumulator{maxBytes: maxBytes, maxFiles: uint64(maxFiles), progress: progress} +} + +// bytesLeft is used to cap streaming copies one byte past the limit so an +// oversized payload is detected instead of silently truncated. +func (a *accumulator) bytesLeft() int64 { + left := int64(a.maxBytes) - a.bytes + if left < 0 { + return 0 + } + + return left +} + +// checkEntryCount rejects an archive whose entry count is known up front and +// already exceeds the limit, so nothing is written before the operation fails. +func (a *accumulator) checkEntryCount(n int) error { + if uint64(n) > a.maxFiles { + return errors.Errorf("max files limit exceeded (%d)", a.maxFiles) + } + + return nil +} + +// addEntry records one processed entry with n uncompressed content bytes. +func (a *accumulator) addEntry(name string, n int64) error { + a.files++ + if uint64(a.files) > a.maxFiles { + return errors.Errorf("max files limit exceeded (%d)", a.maxFiles) + } + + a.bytes += n + if a.bytes > int64(a.maxBytes) { + return errors.Errorf("max total bytes limit exceeded (%d)", a.maxBytes) + } + + if a.progress != nil { + a.progress(a.files, a.bytes, name) + } + + return nil +} + +func ownerOptions(user string, uid, gid int32) osowner.Options { + return osowner.Options{User: user, UID: uid, GID: gid} +} diff --git a/internal/app/archive/create.go b/internal/app/archive/create.go new file mode 100644 index 0000000..61d20e5 --- /dev/null +++ b/internal/app/archive/create.go @@ -0,0 +1,335 @@ +package archive + +import ( + "context" + "io" + "io/fs" + "os" + "path" + + "github.com/pkg/errors" + + "github.com/gameap/daemon/internal/app/fsutil" + "github.com/gameap/daemon/internal/app/osowner" + pb "github.com/gameap/gameap/pkg/proto" +) + +// sourceEntry is one item scheduled for archiving: a regular file, a +// directory or a symlink stored as-is. +type sourceEntry struct { + rel string // path inside the root + name string // entry name inside the archive, relative to base_path + info os.FileInfo + link string // symlink target, set when the symlink is stored as a symlink +} + +func (e sourceEntry) isSymlink() bool { + return e.info.Mode()&os.ModeSymlink != 0 +} + +// Create packs the requested sources into archive_path. See the package doc +// for the confinement model. +func Create(ctx context.Context, workDir string, p *pb.CreateArchiveParams, progress ProgressFunc) (*Result, error) { + if err := ctx.Err(); err != nil { + return nil, errors.Wrap(err, "create archive canceled") + } + + if len(p.GetSources()) == 0 { + return nil, errors.New("no sources given") + } + + root, err := os.OpenRoot(workDir) + if err != nil { + return nil, errors.Wrap(err, "work directory unavailable") + } + defer root.Close() + + archiveRel, err := fsutil.RootRel(p.GetArchivePath()) + if err != nil { + return nil, err + } + + // The proto resolves an unset create format from the target file extension. + format := p.GetFormat() + if format == pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + if format = formatFromExtension(archiveRel); format == pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + return nil, errors.Errorf( + "archive format is unspecified and %q has no known extension", p.GetArchivePath(), + ) + } + } + + class, err := classifyForCreate(format) + if err != nil { + return nil, err + } + + baseRel, err := fsutil.RootRel(p.GetBasePath()) + if err != nil { + return nil, err + } + + if parent := path.Dir(archiveRel); parent != "." && parent != "/" { + if err := root.MkdirAll(parent, 0o755); err != nil { + return nil, errors.Wrapf(err, "failed to create directory %q", parent) + } + } + + flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL + if p.GetOverwrite() { + flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC + } + + archiveFile, err := root.OpenFile(archiveRel, flags, 0o644) + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil, errors.Errorf("archive %q already exists and overwrite is disabled", p.GetArchivePath()) + } + + return nil, errors.Wrap(err, "failed to create archive file") + } + + acc := newAccumulator(p.GetMaxTotalBytes(), p.GetMaxFiles(), progress) + + createErr := createInto(ctx, root, archiveFile, baseRel, p, format, class, acc) + + closeErr := archiveFile.Close() + if createErr != nil { + // A failed operation must not leave a partial archive behind. + _ = root.Remove(archiveRel) + + return nil, createErr + } + if closeErr != nil { + _ = root.Remove(archiveRel) + + return nil, errors.Wrap(closeErr, "failed to close archive file") + } + + // Everything past this point still counts as a failed operation, so the + // archive is removed rather than left behind with the wrong mode or owner. + if p.GetMode() != 0 { + if err := root.Chmod(archiveRel, os.FileMode(p.GetMode()).Perm()); err != nil { + _ = root.Remove(archiveRel) + + return nil, errors.Wrap(err, "failed to chmod archive file") + } + } + + owner := ownerOptions(p.GetOwnerUser(), p.GetOwnerUid(), p.GetOwnerGid()) + if err := osowner.ApplyToPathInRoot(root, archiveRel, owner); err != nil { + _ = root.Remove(archiveRel) + + return nil, errors.Wrap(err, "failed to apply archive owner") + } + + info, err := root.Stat(archiveRel) + if err != nil { + _ = root.Remove(archiveRel) + + return nil, errors.Wrap(err, "failed to stat archive file") + } + + return &Result{ + FilesProcessed: acc.files, + BytesProcessed: acc.bytes, + ArchiveSize: info.Size(), + Format: format, + }, nil +} + +func createInto( + ctx context.Context, + root *os.Root, + archiveFile *os.File, + baseRel string, + p *pb.CreateArchiveParams, + format pb.ArchiveFormat, + class formatClass, + acc *accumulator, +) error { + archiveInfo, err := archiveFile.Stat() + if err != nil { + return errors.Wrap(err, "failed to stat archive file") + } + + entries, err := collectSources(ctx, root, baseRel, p.GetSources(), &walkLimits{ + follow: p.GetFollowSymlinks(), + maxEntries: acc.maxFiles, + archive: archiveInfo, + }) + if err != nil { + return err + } + + if class == classSingle { + return createSingle(root, archiveFile, entries, p, format, acc) + } + + if len(entries) == 0 { + return errors.New("nothing to archive: sources contain no files") + } + + if class == classZip { + return createZip(ctx, root, archiveFile, entries, p, acc) + } + + return createTar(ctx, root, archiveFile, entries, p, tarCompression(format), acc) +} + +// walkLimits bounds one source expansion. +type walkLimits struct { + follow bool + // maxEntries stops the expansion itself, not just the write phase. The + // whole entry list is materialized before a single byte is archived, and + // with follow_symlinks a handful of links fans out into an enormous number + // of distinct paths, so the limit has to apply here too. + maxEntries uint64 + // archive identifies the file being written, so it is never archived into + // itself. Comparing identity rather than the path name also covers reaching + // it through a symlink, where the path differs. + archive os.FileInfo +} + +// sourceWalker expands the request sources (relative to base_path) into a flat +// entry list. Directories are walked recursively; symlinks are stored as +// symlinks unless follow is set, in which case the symlink target contents are +// archived (os.Root still refuses targets outside the work directory). +type sourceWalker struct { + ctx context.Context + root *os.Root + limits *walkLimits + entries []sourceEntry +} + +// collectSources expands every source into one flat, bounded entry list. +func collectSources( + ctx context.Context, root *os.Root, baseRel string, sources []string, limits *walkLimits, +) ([]sourceEntry, error) { + w := &sourceWalker{ctx: ctx, root: root, limits: limits} + + for _, src := range sources { + srcRel, err := fsutil.RootRel(src) + if err != nil { + return nil, errors.Wrapf(err, "invalid source %q", src) + } + + rel := srcRel + if baseRel != "." { + rel = path.Join(baseRel, srcRel) + } + + if err := w.walk(rel, srcRel, 0); err != nil { + return nil, err + } + } + + return w.entries, nil +} + +func (w *sourceWalker) add(e sourceEntry) error { + if uint64(len(w.entries)) >= w.limits.maxEntries { + return errors.Errorf("max files limit exceeded (%d)", w.limits.maxEntries) + } + + w.entries = append(w.entries, e) + + return nil +} + +// copySource streams one source file into an archive entry writer, capping +// the read one byte past the remaining byte budget so an oversized payload is +// reported by the accumulator instead of silently truncated. +func copySource(root *os.Root, rel string, w io.Writer, bytesLeft int64) (int64, error) { + src, err := root.Open(rel) + if err != nil { + return 0, errors.Wrapf(err, "failed to open source %q", rel) + } + defer src.Close() + + n, err := io.Copy(w, io.LimitReader(src, bytesLeft+1)) + if err != nil { + return 0, errors.Wrapf(err, "failed to write %q", rel) + } + + return n, nil +} + +func (w *sourceWalker) walk(rel, name string, symlinkDepth int) error { + if err := w.ctx.Err(); err != nil { + return errors.Wrap(err, "create archive canceled") + } + + info, err := w.root.Lstat(rel) + if err != nil { + return errors.Wrapf(err, "failed to stat source %q", rel) + } + + if os.SameFile(info, w.limits.archive) { + return nil + } + + if info.Mode()&os.ModeSymlink != 0 && w.limits.follow { + symlinkDepth++ + if symlinkDepth > maxFollowDepth { + return errors.Errorf("symlink nesting too deep at %q", rel) + } + + info, err = w.root.Stat(rel) + if err != nil { + return errors.Wrapf(err, "failed to resolve symlink %q", rel) + } + + // Following the link is what makes it point at a file: a link aimed at + // the archive only becomes the archive here, after the resolution. + if os.SameFile(info, w.limits.archive) { + return nil + } + } + + switch { + case info.IsDir(): + return w.walkDir(rel, name, info, symlinkDepth) + case info.Mode()&os.ModeSymlink != 0: + link, err := w.root.Readlink(rel) + if err != nil { + return errors.Wrapf(err, "failed to read symlink %q", rel) + } + + return w.add(sourceEntry{rel: rel, name: name, info: info, link: link}) + case info.Mode().IsRegular(): + return w.add(sourceEntry{rel: rel, name: name, info: info}) + default: + // Sockets, fifos and device nodes cannot be archived; game-server work + // directories legitimately contain unix sockets. Matches fsutil.Copy. + return nil + } +} + +func (w *sourceWalker) walkDir(rel, name string, info os.FileInfo, symlinkDepth int) error { + // The "." source contributes its children only; storing "." itself would + // produce a useless root entry. + if name != "." { + if err := w.add(sourceEntry{rel: rel, name: name, info: info}); err != nil { + return err + } + } + + dirEntries, err := fs.ReadDir(w.root.FS(), rel) + if err != nil { + return errors.Wrapf(err, "failed to read directory %q", rel) + } + + for _, child := range dirEntries { + childName := child.Name() + if name != "." { + childName = path.Join(name, child.Name()) + } + + if err := w.walk(path.Join(rel, child.Name()), childName, symlinkDepth); err != nil { + return err + } + } + + return nil +} diff --git a/internal/app/archive/create_single.go b/internal/app/archive/create_single.go new file mode 100644 index 0000000..61ac557 --- /dev/null +++ b/internal/app/archive/create_single.go @@ -0,0 +1,55 @@ +package archive + +import ( + "io" + "os" + + "github.com/pkg/errors" + + pb "github.com/gameap/gameap/pkg/proto" +) + +// createSingle writes the gz/bz2/xz/zstd single-file formats: the archive is +// the compressed stream of exactly one regular file. +func createSingle( + root *os.Root, + archiveFile io.Writer, + entries []sourceEntry, + p *pb.CreateArchiveParams, + format pb.ArchiveFormat, + acc *accumulator, +) error { + if len(entries) != 1 || !entries[0].info.Mode().IsRegular() { + return errors.Errorf( + "archive format %s requires exactly one regular file source", format, + ) + } + + e := entries[0] + + stream, closer, err := compressWriter(archiveFile, singleCompression(format), p.CompressionLevel) + if err != nil { + return err + } + + closed := false + defer func() { + if closer != nil && !closed { + _ = closer.Close() + } + }() + + n, err := copySource(root, e.rel, stream, acc.bytesLeft()) + if err != nil { + return err + } + + if closer != nil { + closed = true + if err := closer.Close(); err != nil { + return errors.Wrap(err, "failed to finish compressor stream") + } + } + + return acc.addEntry(e.name, n) +} diff --git a/internal/app/archive/create_tar.go b/internal/app/archive/create_tar.go new file mode 100644 index 0000000..889b6bd --- /dev/null +++ b/internal/app/archive/create_tar.go @@ -0,0 +1,166 @@ +package archive + +import ( + "archive/tar" + "compress/bzip2" + "compress/gzip" + "context" + "io" + "os" + "strings" + + "github.com/pkg/errors" + + dsbzip2 "github.com/dsnet/compress/bzip2" + pb "github.com/gameap/gameap/pkg/proto" + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" +) + +// compressWriter wraps w with the requested stream compressor. The returned +// closer must be closed before w itself is closed; it closes only the +// compressor stream. +func compressWriter(w io.Writer, comp compression, level *int32) (io.Writer, io.Closer, error) { + switch comp { + case compGzip: + gw, err := gzip.NewWriterLevel(w, gzipLevel(level)) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to init gzip writer") + } + + return gw, gw, nil + case compBzip2: + bw, err := dsbzip2.NewWriter(w, &dsbzip2.WriterConfig{Level: bzip2Level(level)}) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to init bzip2 writer") + } + + return bw, bw, nil + case compXz: + // The xz format has no compression levels (only filter presets), so a + // requested level is silently ignored here. + xw, err := xz.NewWriter(w) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to init xz writer") + } + + return xw, xw, nil + case compZstd: + zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(zstdLevel(level))) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to init zstd writer") + } + + return zw, zw, nil + default: + return w, nil, nil + } +} + +// decompressReader wraps r with the matching stream decompressor. Closing +// the returned reader releases the decompressor; it never closes r itself. +func decompressReader(r io.Reader, comp compression) (io.ReadCloser, error) { + switch comp { + case compGzip: + gr, err := gzip.NewReader(r) + if err != nil { + return nil, errors.Wrap(err, "failed to init gzip reader") + } + + return gr, nil + case compBzip2: + // stdlib on the read path: it is continuously fuzzed and reports + // corruption as an error. dsnet/compress is kept for writing only — + // it is the one that exposes compression levels, but it decodes with + // panic-as-control-flow behind a recover that re-raises anything it + // does not recognize, which is not what should face untrusted input. + return io.NopCloser(bzip2.NewReader(r)), nil + case compXz: + xr, err := xz.NewReader(r) + if err != nil { + return nil, errors.Wrap(err, "failed to init xz reader") + } + + return io.NopCloser(xr), nil + case compZstd: + zr, err := zstd.NewReader(r) + if err != nil { + return nil, errors.Wrap(err, "failed to init zstd reader") + } + + return zr.IOReadCloser(), nil + default: + return io.NopCloser(r), nil + } +} + +// createTar writes entries into a tar stream, optionally through a stream +// compressor (tar.gz, tar.bz2, tar.xz, tar.zst). +func createTar( + ctx context.Context, + root *os.Root, + w io.Writer, + entries []sourceEntry, + p *pb.CreateArchiveParams, + comp compression, + acc *accumulator, +) error { + stream, closer, err := compressWriter(w, comp, p.CompressionLevel) + if err != nil { + return err + } + + closed := false + defer func() { + if closer != nil && !closed { + _ = closer.Close() + } + }() + + tw := tar.NewWriter(stream) + + for _, e := range entries { + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "create archive canceled") + } + + hdr, err := tar.FileInfoHeader(e.info, e.link) + if err != nil { + return errors.Wrapf(err, "failed to build header for %q", e.name) + } + + hdr.Name = e.name + if e.info.IsDir() && !strings.HasSuffix(hdr.Name, "/") { + hdr.Name += "/" + } + + if err := tw.WriteHeader(hdr); err != nil { + return errors.Wrapf(err, "failed to write header for %q", e.name) + } + + var n int64 + if hdr.Typeflag == tar.TypeReg { + n, err = copySource(root, e.rel, tw, acc.bytesLeft()) + if err != nil { + return err + } + } + + if err := acc.addEntry(e.name, n); err != nil { + return err + } + } + + if err := tw.Close(); err != nil { + return errors.Wrap(err, "failed to finish tar archive") + } + + if closer != nil { + closed = true + if err := closer.Close(); err != nil { + return errors.Wrap(err, "failed to finish compressor stream") + } + } + + return nil +} diff --git a/internal/app/archive/create_test.go b/internal/app/archive/create_test.go new file mode 100644 index 0000000..f4e3ab2 --- /dev/null +++ b/internal/app/archive/create_test.go @@ -0,0 +1,724 @@ +package archive + +import ( + "context" + "fmt" + "math" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pb "github.com/gameap/gameap/pkg/proto" +) + +var roundtripFormats = []struct { + name string + format pb.ArchiveFormat + ext string +}{ + {"zip", pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, "zip"}, + {"tar", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, "tar"}, + {"tar.gz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ, "tar.gz"}, + {"tar.bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2, "tar.bz2"}, + {"tar.xz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ, "tar.xz"}, + {"tar.zst", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD, "tar.zst"}, +} + +func TestCreateExtractRoundtrip(t *testing.T) { + srcFiles := map[string]string{ + "src/a.txt": "alpha", + "src/sub/b.txt": "bravo\nsecond line", + "src/sub/c empty": "", + "src/deep/d/e.txt": "echo", + } + + for _, tc := range roundtripFormats { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, srcFiles) + + progress := &progressRecord{} + createRes, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out." + tc.ext, + Format: tc.format, + BasePath: ".", + Sources: []string{"src"}, + }, progress.fn()) + require.NoError(t, err) + assert.Positive(t, createRes.FilesProcessed) + assert.Positive(t, createRes.BytesProcessed) + assert.Positive(t, createRes.ArchiveSize) + assert.Equal(t, createRes.FilesProcessed, progress.files, "progress must track processed entries") + assert.NotEmpty(t, progress.entries) + + extractRes, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out." + tc.ext, + Destination: "dst", + Format: tc.format, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Positive(t, extractRes.FilesProcessed) + assert.Empty(t, extractRes.Skipped) + + want := map[string]string{ + "src/a.txt": "alpha", + "src/sub/b.txt": "bravo\nsecond line", + "src/sub/c empty": "", + "src/deep/d/e.txt": "echo", + } + assert.Equal(t, want, readTree(t, filepath.Join(workDir, "dst"))) + }) + } +} + +func TestCreateExtractRoundtripBasePath(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{ + "base/one.txt": "1", + "base/dir/two.txt": "2", + }) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + BasePath: "base", + Sources: []string{"."}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{ + "one.txt": "1", + "dir/two.txt": "2", + }, readTree(t, filepath.Join(workDir, "dst"))) +} + +var singleFormats = []struct { + name string + format pb.ArchiveFormat + ext string +}{ + {"gz", pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, "gz"}, + {"bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2, "bz2"}, + {"xz", pb.ArchiveFormat_ARCHIVE_FORMAT_XZ, "xz"}, + {"zst", pb.ArchiveFormat_ARCHIVE_FORMAT_ZSTD, "zst"}, +} + +func TestSingleFileRoundtrip(t *testing.T) { + for _, tc := range singleFormats { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"data.bin": "single file payload"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "data.bin." + tc.ext, + Format: tc.format, + BasePath: ".", + Sources: []string{"data.bin"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "data.bin." + tc.ext, + Destination: "dst", + Format: tc.format, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"data.bin": "single file payload"}, + readTree(t, filepath.Join(workDir, "dst"))) + }) + } +} + +func TestSingleFileExtractNoSuffix(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"payload": "no suffix payload"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "compressed", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + BasePath: ".", + Sources: []string{"payload"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "compressed", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"compressed.out": "no suffix payload"}, + readTree(t, filepath.Join(workDir, "dst"))) +} + +func TestCreateErrors(t *testing.T) { + t.Run("unspecified format without a known extension", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.bundle", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, + Sources: []string{"a.txt"}, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no known extension") + }) + + t.Run("extract-only formats", func(t *testing.T) { + for _, format := range []pb.ArchiveFormat{ + pb.ArchiveFormat_ARCHIVE_FORMAT_7Z, + pb.ArchiveFormat_ARCHIVE_FORMAT_RAR, + } { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.arc", + Format: format, + Sources: []string{"a.txt"}, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "extract-only") + } + }) + + t.Run("empty sources", func(t *testing.T) { + workDir := t.TempDir() + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{}, + }, nil) + require.Error(t, err) + }) + + t.Run("existing archive without overwrite", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a", "out.zip": "old"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + content, readErr := os.ReadFile(filepath.Join(workDir, "out.zip")) + require.NoError(t, readErr) + assert.Equal(t, "old", string(content), "existing archive must stay untouched") + }) + + t.Run("existing archive with overwrite", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a", "out.zip": "old"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + Overwrite: true, + }, nil) + require.NoError(t, err) + }) + + t.Run("single format with two sources", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a", "b.txt": "b"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.gz", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + Sources: []string{"a.txt", "b.txt"}, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one") + }) + + t.Run("single format with directory source", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"dir/a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.gz", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + Sources: []string{"dir"}, + }, nil) + require.Error(t, err) + }) + + t.Run("source escapes base path", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + BasePath: "sub", + Sources: []string{"../a.txt"}, + }, nil) + require.Error(t, err) + }) +} + +func TestCreateMaxFilesLimit(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a", "b.txt": "b"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt", "b.txt"}, + MaxFiles: 1, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max files limit exceeded") + + _, statErr := os.Stat(filepath.Join(workDir, "out.zip")) + assert.True(t, os.IsNotExist(statErr), "failed create must not leave a partial archive") +} + +func TestCreateMaxTotalBytesLimit(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "more than one byte"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + MaxTotalBytes: 1, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max total bytes limit exceeded") +} + +// TestCreateExtractHugeMaxTotalBytes pins the byte budget at the very top of +// its range: every streaming copy is capped one byte past what is left, so the +// largest limit a request can ask for must still archive and extract content +// instead of overflowing that cap into a read that yields nothing. +func TestCreateExtractHugeMaxTotalBytes(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/a.txt": "alpha"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + BasePath: "src", + Sources: []string{"."}, + MaxTotalBytes: math.MaxUint64, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + MaxTotalBytes: math.MaxUint64, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"a.txt": "alpha"}, readTree(t, filepath.Join(workDir, "dst"))) +} + +func TestCreateModeOnArchive(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission bits are not supported on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + Mode: 0o600, + }, nil) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(workDir, "out.zip")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestCreateCanceledContext(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(canceledContext(t), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + }, nil) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestCreateCompressionLevels(t *testing.T) { + levels := map[string]*int32{ + "store": new(int32), + "fastest": new(int32(1)), + "best": new(int32(9)), + } + + formats := []struct { + name string + format pb.ArchiveFormat + }{ + {"zip", pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP}, + {"tar.gz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ}, + {"tar.bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2}, + {"tar.zst", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD}, + } + + for _, f := range formats { + for levelName, level := range levels { + t.Run(f.name+"/"+levelName, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "compressible payload payload payload"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.arc", + Format: f.format, + Sources: []string{"a.txt"}, + CompressionLevel: level, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.arc", + Destination: "dst", + Format: f.format, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"a.txt": "compressible payload payload payload"}, + readTree(t, filepath.Join(workDir, "dst"))) + }) + } + } +} + +func TestSymlinkRoundtrip(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + for _, tc := range []struct { + name string + format pb.ArchiveFormat + ext string + }{ + {"zip", pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, "zip"}, + {"tar", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, "tar"}, + } { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/target.txt": "link me"}) + require.NoError(t, os.Symlink("target.txt", filepath.Join(workDir, "src", "link.txt"))) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out." + tc.ext, + Format: tc.format, + Sources: []string{"src"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out." + tc.ext, + Destination: "dst", + Format: tc.format, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + link, err := os.Readlink(filepath.Join(workDir, "dst", "src", "link.txt")) + require.NoError(t, err) + assert.Equal(t, "target.txt", link) + }) + } +} + +func TestCreateFollowSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/target.txt": "followed"}) + require.NoError(t, os.Symlink("target.txt", filepath.Join(workDir, "src", "link.txt"))) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"src"}, + FollowSymlinks: true, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + info, err := os.Lstat(filepath.Join(workDir, "dst", "src", "link.txt")) + require.NoError(t, err) + assert.True(t, info.Mode().IsRegular(), "followed symlink must be archived as a regular file") + + content, err := os.ReadFile(filepath.Join(workDir, "dst", "src", "link.txt")) + require.NoError(t, err) + assert.Equal(t, "followed", string(content)) +} + +func TestCreateDeepDirectoryNesting(t *testing.T) { + workDir := t.TempDir() + + deep := "src" + for range maxFollowDepth + 10 { + deep += "/d" + } + writeTree(t, workDir, map[string]string{deep + "/file.txt": "deep"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"src"}, + }, nil) + require.NoError(t, err, "deep ordinary directory nesting must not trigger the symlink depth limit") +} + +func TestCreateFollowSymlinksDepthLimit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/file.txt": "data"}) + require.NoError(t, os.Symlink(".", filepath.Join(workDir, "src", "loop"))) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"src"}, + FollowSymlinks: true, + }, nil) + require.Error(t, err) + // The daemon's own hop limit fires at maxFollowDepth+1; the OS may reject + // the accumulated path earlier with its own symlink expansion limit. + assert.True(t, + strings.Contains(err.Error(), "symlink nesting too deep") || + strings.Contains(err.Error(), "too many levels of symbolic links"), + "unexpected error: %v", err) +} + +// TestWalkSourceSymlinkDepthLimit checks the daemon's own hop counter +// deterministically: following one more symlink past maxFollowDepth fails +// before any OS-level resolution is attempted. +func TestWalkSourceSymlinkDepthLimit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/file.txt": "data"}) + require.NoError(t, os.Symlink(".", filepath.Join(workDir, "src", "loop"))) + + root, err := os.OpenRoot(workDir) + require.NoError(t, err) + defer root.Close() + + w := &sourceWalker{ + ctx: context.Background(), + root: root, + limits: &walkLimits{follow: true, maxEntries: defaultMaxFilesLimit()}, + } + + err = w.walk("src/loop", "src/loop", maxFollowDepth) + require.Error(t, err) + assert.Contains(t, err.Error(), "symlink nesting too deep") +} + +func defaultMaxFilesLimit() uint64 { + return uint64(defaultMaxFiles) +} + +// TestCreateFollowSymlinksFanOutIsBounded builds a symlink DAG that stays under +// os.Root's own 8-hop limit, so no single path is ever rejected while the +// number of distinct paths grows exponentially. The entry limit has to apply +// during the walk, not only when entries are written. +func TestCreateFollowSymlinksFanOutIsBounded(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + const levels = 7 + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{ + filepath.ToSlash(filepath.Join("src", fmt.Sprintf("d%d", levels), "leaf.txt")): "leaf", + }) + + for lvl := range levels { + dir := filepath.Join(workDir, "src", fmt.Sprintf("d%d", lvl)) + require.NoError(t, os.MkdirAll(dir, 0o755)) + + for k := range 3 { + require.NoError(t, os.Symlink( + fmt.Sprintf("../d%d", lvl+1), + filepath.Join(dir, fmt.Sprintf("l%d", k)), + )) + } + } + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"src"}, + FollowSymlinks: true, + MaxFiles: 50, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max files limit exceeded") +} + +func TestSourceWalkerRespectsContext(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/a.txt": "a"}) + + root, err := os.OpenRoot(workDir) + require.NoError(t, err) + defer root.Close() + + w := &sourceWalker{ + ctx: canceledContext(t), + root: root, + limits: &walkLimits{maxEntries: defaultMaxFilesLimit()}, + } + + err = w.walk("src", "src", 0) + require.ErrorIs(t, err, context.Canceled) +} + +// TestCreateExcludesItself covers the archive sitting inside the tree being +// walked: it must not be archived into itself while it is being written. +func TestCreateExcludesItself(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/a.txt": "alpha"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "src/out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + BasePath: "src", + Sources: []string{"."}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "src/out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"a.txt": "alpha"}, readTree(t, filepath.Join(workDir, "dst"))) +} + +// TestCreateExcludesItselfThroughSymlink covers the same exclusion reached +// through a symlink: with follow_symlinks the walker resolves the link to the +// archive it is writing, which the identity comparison has to catch after the +// resolution as well as before it. +func TestCreateExcludesItselfThroughSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/a.txt": "alpha"}) + require.NoError(t, os.Symlink("out.zip", filepath.Join(workDir, "src", "self.zip"))) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "src/out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + BasePath: "src", + Sources: []string{"."}, + FollowSymlinks: true, + // Bounds what archiving the archive into itself could produce if the + // exclusion ever regresses. + MaxTotalBytes: 1 << 20, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "src/out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"a.txt": "alpha"}, readTree(t, filepath.Join(workDir, "dst"))) +} + +// TestFormatDetection checks the proto contract that an unset format is +// resolved from the archive itself, including telling tar.gz from a bare gz — +// the two share a magic number and differ only in what the stream contains. +func TestFormatDetection(t *testing.T) { + detect := func(t *testing.T, name, ext, source string, format pb.ArchiveFormat) { + t.Helper() + + t.Run(name, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/a.txt": "alpha"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out." + ext, + Format: format, + Sources: []string{source}, + }, nil) + require.NoError(t, err) + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out." + ext, + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, format, res.Format) + }) + } + + for _, tc := range roundtripFormats { + detect(t, tc.name, tc.ext, "src", tc.format) + } + + for _, tc := range singleFormats { + detect(t, tc.name, tc.ext, "src/a.txt", tc.format) + } +} diff --git a/internal/app/archive/create_zip.go b/internal/app/archive/create_zip.go new file mode 100644 index 0000000..bd37e87 --- /dev/null +++ b/internal/app/archive/create_zip.go @@ -0,0 +1,90 @@ +package archive + +import ( + "archive/zip" + "compress/flate" + "context" + "io" + "os" + + "github.com/pkg/errors" + + pb "github.com/gameap/gameap/pkg/proto" +) + +// createZip writes entries into a zip stream. Compression level 0 maps to +// per-entry Store; other levels tune the deflate compressor. +func createZip( + ctx context.Context, + root *os.Root, + w io.Writer, + entries []sourceEntry, + p *pb.CreateArchiveParams, + acc *accumulator, +) error { + zw := zip.NewWriter(w) + + store := false + if p.CompressionLevel != nil { + if *p.CompressionLevel == 0 { + store = true + } else { + level := flateLevel(p.CompressionLevel) + zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(out, level) + }) + } + } + + for _, e := range entries { + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "create archive canceled") + } + + hdr, err := zip.FileInfoHeader(e.info) + if err != nil { + return errors.Wrapf(err, "failed to build header for %q", e.name) + } + + hdr.Name = e.name + if e.info.IsDir() { + hdr.Name += "/" + } + + if store || e.isSymlink() { + hdr.Method = zip.Store + } + + entryWriter, err := zw.CreateHeader(hdr) + if err != nil { + return errors.Wrapf(err, "failed to write header for %q", e.name) + } + + var n int64 + + switch { + case e.info.IsDir(): + case e.isSymlink(): + nw, writeErr := entryWriter.Write([]byte(e.link)) + n = int64(nw) + if writeErr != nil { + return errors.Wrapf(writeErr, "failed to write symlink %q", e.name) + } + default: + n, err = copySource(root, e.rel, entryWriter, acc.bytesLeft()) + if err != nil { + return err + } + } + + if err := acc.addEntry(e.name, n); err != nil { + return err + } + } + + if err := zw.Close(); err != nil { + return errors.Wrap(err, "failed to finish zip archive") + } + + return nil +} diff --git a/internal/app/archive/detect.go b/internal/app/archive/detect.go new file mode 100644 index 0000000..713d54e --- /dev/null +++ b/internal/app/archive/detect.go @@ -0,0 +1,195 @@ +package archive + +import ( + "bytes" + "io" + "os" + "path" + "strings" + + "github.com/pkg/errors" + + pb "github.com/gameap/gameap/pkg/proto" +) + +// headerPeekBytes is one tar block: enough for every container signature below +// and for the "ustar" marker a tar header carries at offset 257. +const headerPeekBytes = 512 + +// tarMagicOffset is where a POSIX tar header stores "ustar". +const tarMagicOffset = 257 + +var tarMagic = []byte("ustar") + +// extensionFormats maps a file suffix onto the format it conventionally names. +// Longest suffix wins, so ".tar.gz" is matched before ".gz". +var extensionFormats = []struct { + suffix string + format pb.ArchiveFormat +}{ + {".tar.gz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ}, + {".tar.bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2}, + {".tar.xz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ}, + {".tar.zst", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD}, + {".tgz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ}, + {".tbz2", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2}, + {".tbz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2}, + {".txz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ}, + {".tzst", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD}, + {".tar", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR}, + {".zip", pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP}, + {".7z", pb.ArchiveFormat_ARCHIVE_FORMAT_7Z}, + {".rar", pb.ArchiveFormat_ARCHIVE_FORMAT_RAR}, + {".gz", pb.ArchiveFormat_ARCHIVE_FORMAT_GZ}, + {".bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2}, + {".xz", pb.ArchiveFormat_ARCHIVE_FORMAT_XZ}, + {".zst", pb.ArchiveFormat_ARCHIVE_FORMAT_ZSTD}, +} + +// formatFromExtension resolves a format from a file name, returning +// ARCHIVE_FORMAT_UNSPECIFIED when no known suffix matches. +func formatFromExtension(name string) pb.ArchiveFormat { + lower := strings.ToLower(path.Base(name)) + + for _, e := range extensionFormats { + if strings.HasSuffix(lower, e.suffix) { + return e.format + } + } + + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED +} + +// containerFromMagic recognizes the self-describing container formats. +func containerFromMagic(head []byte) (pb.ArchiveFormat, bool) { + switch { + case bytes.HasPrefix(head, []byte("PK\x03\x04")), + bytes.HasPrefix(head, []byte("PK\x05\x06")), + bytes.HasPrefix(head, []byte("PK\x07\x08")): + return pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, true + case bytes.HasPrefix(head, []byte("7z\xbc\xaf\x27\x1c")): + return pb.ArchiveFormat_ARCHIVE_FORMAT_7Z, true + case bytes.HasPrefix(head, []byte("Rar!\x1a\x07")): + return pb.ArchiveFormat_ARCHIVE_FORMAT_RAR, true + case looksLikeTar(head): + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, true + default: + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, false + } +} + +// compressionFromMagic recognizes the stream compressors. They say nothing +// about whether a tar sits inside, which is resolved separately. +func compressionFromMagic(head []byte) (compression, bool) { + switch { + case bytes.HasPrefix(head, []byte("\x1f\x8b")): + return compGzip, true + case bytes.HasPrefix(head, []byte("BZh")): + return compBzip2, true + case bytes.HasPrefix(head, []byte("\xfd7zXZ\x00")): + return compXz, true + case bytes.HasPrefix(head, []byte("\x28\xb5\x2f\xfd")): + return compZstd, true + default: + return compNone, false + } +} + +func looksLikeTar(head []byte) bool { + if len(head) < tarMagicOffset+len(tarMagic) { + return false + } + + return bytes.Equal(head[tarMagicOffset:tarMagicOffset+len(tarMagic)], tarMagic) +} + +// detectFormat resolves ARCHIVE_FORMAT_UNSPECIFIED for extraction the way the +// proto describes: by magic bytes, falling back to the file extension. The +// file offset is restored before returning, so the caller can read from the +// start regardless of how much was consumed while sniffing. +func detectFormat(f *os.File, name string) (pb.ArchiveFormat, error) { + defer func() { + _, _ = f.Seek(0, io.SeekStart) + }() + + head := make([]byte, headerPeekBytes) + n, err := f.ReadAt(head, 0) + if err != nil && !errors.Is(err, io.EOF) { + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, errors.Wrapf( + err, "failed to read header of %q", name, + ) + } + head = head[:n] + + if format, ok := containerFromMagic(head); ok { + return format, nil + } + + if comp, ok := compressionFromMagic(head); ok { + return compressedFormat(f, comp), nil + } + + if format := formatFromExtension(name); format != pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + return format, nil + } + + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, errors.Errorf( + "cannot detect the format of archive %q", name, + ) +} + +// compressedFormat decides whether a compressed stream carries a tar or a bare +// file by decompressing just the first tar block and looking for its marker. +// A stream that cannot be decompressed is reported as single-file; opening it +// for real will surface the actual error. +func compressedFormat(f *os.File, comp compression) pb.ArchiveFormat { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return singleFormatFor(comp) + } + + stream, err := decompressReader(f, comp) + if err != nil { + return singleFormatFor(comp) + } + defer stream.Close() + + head := make([]byte, headerPeekBytes) + n, err := io.ReadFull(stream, head) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return singleFormatFor(comp) + } + + if looksLikeTar(head[:n]) { + return tarFormatFor(comp) + } + + return singleFormatFor(comp) +} + +func tarFormatFor(comp compression) pb.ArchiveFormat { + switch comp { + case compGzip: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ + case compBzip2: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2 + case compXz: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ + case compZstd: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD + default: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR + } +} + +func singleFormatFor(comp compression) pb.ArchiveFormat { + switch comp { + case compGzip: + return pb.ArchiveFormat_ARCHIVE_FORMAT_GZ + case compBzip2: + return pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2 + case compXz: + return pb.ArchiveFormat_ARCHIVE_FORMAT_XZ + default: + return pb.ArchiveFormat_ARCHIVE_FORMAT_ZSTD + } +} diff --git a/internal/app/archive/extract.go b/internal/app/archive/extract.go new file mode 100644 index 0000000..58730d8 --- /dev/null +++ b/internal/app/archive/extract.go @@ -0,0 +1,557 @@ +package archive + +import ( + "context" + "io" + "os" + "path" + "strings" + + "github.com/pkg/errors" + + "github.com/gameap/daemon/internal/app/fsutil" + "github.com/gameap/daemon/internal/app/osowner" + pb "github.com/gameap/gameap/pkg/proto" +) + +const ( + defaultFilePerm os.FileMode = 0o644 + defaultDirPerm os.FileMode = 0o755 +) + +// sink places extracted entries under the destination inside the root, +// applying the conflict policy, permission rules, ownership and limits. +type sink struct { + root *os.Root + dest string + policy pb.ArchiveConflictPolicy + preserve bool + mode os.FileMode // when != 0 overrides file permissions from the archive + owner osowner.Options + acc *accumulator + skipped []string + links []createdLink +} + +// createdLink records a symlink this run put on disk so its target can be +// checked again once the archive can no longer move anything. +type createdLink struct { + path string // where the link was stored, relative to the root + target string // literal target as it came out of the archive +} + +// safeEntryName validates an archive entry name and returns its path relative +// to the root (dest-prefixed). ok=false means the entry is a "." artifact and +// should be skipped silently. Absolute names and names escaping the +// destination through ".." are rejected (zip-slip); os.Root remains the hard +// boundary against symlink escapes below. +func (s *sink) safeEntryName(name string) (target string, ok bool, err error) { + // Windows-produced archives may use backslashes as separators; normalize + // so ".." segments hidden behind them are caught too. + clean := path.Clean(strings.ReplaceAll(name, `\`, "/")) + if clean == "." { + return "", false, nil + } + + if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") { + return "", false, errors.Errorf("archive entry %q escapes the destination", name) + } + + return path.Join(s.dest, clean), true, nil +} + +func (s *sink) resolveConflict(target string, isDir bool) (skip, existed bool, err error) { + existing, lstatErr := s.root.Lstat(target) + if lstatErr != nil { + if errors.Is(lstatErr, os.ErrNotExist) { + return false, false, nil + } + + return false, false, errors.Wrapf(lstatErr, "failed to stat %q", target) + } + + // An existing directory merging with a directory entry is not a conflict. + if isDir && existing.IsDir() { + return false, true, nil + } + + switch s.policy { + case pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_SKIP: + return true, true, nil + case pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_OVERWRITE: + if existing.IsDir() { + if err := s.root.RemoveAll(target); err != nil { + return false, true, errors.Wrapf(err, "failed to remove %q", target) + } + } else if err := s.root.Remove(target); err != nil { + return false, true, errors.Wrapf(err, "failed to remove %q", target) + } + + return false, false, nil + default: // UNSPECIFIED and ERROR both fail fast, per the proto contract + return false, true, errors.Errorf("destination entry %q already exists", target) + } +} + +func (s *sink) filePerm(archiveMode os.FileMode) os.FileMode { + if s.mode != 0 { + return s.mode + } + if s.preserve && archiveMode.Perm() != 0 { + return archiveMode.Perm() + } + + return defaultFilePerm +} + +func (s *sink) dirPerm(archiveMode os.FileMode) os.FileMode { + if s.preserve && archiveMode.Perm() != 0 { + return archiveMode.Perm() + } + + return defaultDirPerm +} + +func (s *sink) putDir(name string, archiveMode os.FileMode) error { + target, ok, err := s.safeEntryName(name) + if err != nil { + return err + } + if !ok { + return nil + } + + skip, existed, err := s.resolveConflict(target, true) + if err != nil { + return err + } + if skip { + s.skipped = append(s.skipped, name) + + return nil + } + + if err := mkdirAllOwned(s.root, target, s.dirPerm(archiveMode), s.owner); err != nil { + return err + } + + // MkdirAll applies umask; chmod for the exact requested permissions. A + // pre-existing directory merged into keeps its mode unless permissions + // are explicitly preserved from the archive. + if !existed || s.preserve { + if err := s.root.Chmod(target, s.dirPerm(archiveMode)); err != nil { + return errors.Wrapf(err, "failed to chmod directory %q", target) + } + } + + if err := osowner.ApplyToPathInRoot(s.root, target, s.owner); err != nil { + return errors.Wrapf(err, "failed to apply owner to %q", target) + } + + return s.acc.addEntry(name, 0) +} + +func (s *sink) putFile(name string, archiveMode os.FileMode, r io.Reader) error { + target, ok, err := s.safeEntryName(name) + if err != nil { + return err + } + if !ok { + // A "." entry still has to be consumed by sequential readers; the + // caller passes the reader and drains it here. + _, err := io.Copy(io.Discard, r) + + return errors.Wrap(err, "failed to skip archive entry") + } + + skip, _, err := s.resolveConflict(target, false) + if err != nil { + return err + } + if skip { + s.skipped = append(s.skipped, name) + _, err := io.Copy(io.Discard, r) + + return errors.Wrap(err, "failed to skip archive entry") + } + + perm := s.filePerm(archiveMode) + + if err := s.ensureParent(target); err != nil { + return err + } + + out, err := s.root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return errors.Wrapf(err, "failed to create file %q", target) + } + + n, copyErr := io.Copy(out, io.LimitReader(r, s.acc.bytesLeft()+1)) + closeErr := out.Close() + + if copyErr != nil { + _ = s.root.Remove(target) + + return errors.Wrapf(copyErr, "failed to write file %q", target) + } + if closeErr != nil { + _ = s.root.Remove(target) + + return errors.Wrapf(closeErr, "failed to close file %q", target) + } + + if err := s.acc.addEntry(name, n); err != nil { + _ = s.root.Remove(target) + + return err + } + + // OpenFile applies umask; chmod for the exact requested permissions. + if err := s.root.Chmod(target, perm); err != nil { + return errors.Wrapf(err, "failed to chmod file %q", target) + } + + if err := osowner.ApplyToPathInRoot(s.root, target, s.owner); err != nil { + return errors.Wrapf(err, "failed to apply owner to %q", target) + } + + return nil +} + +// maxSymlinkResolveHops bounds symlink expansion while validating a link +// target. It matches os.Root's own rootMaxSymlinks, so a target accepted here +// is one os.Root will also agree to resolve later. +const maxSymlinkResolveHops = 8 + +func (s *sink) withinDest(resolved string) bool { + if s.dest == "." { + return resolved != ".." && !strings.HasPrefix(resolved, "../") + } + + return resolved == s.dest || strings.HasPrefix(resolved, s.dest+"/") +} + +// resolveLinkTarget resolves linkTarget the way the kernel would: relative to +// the directory holding the link, expanding any path component that is itself +// a symlink already present under the root. +// +// Resolving lexically is not enough. An archive can first store dst/a/b/l with +// target "../..", which cleans to dst and is accepted, and then store dst/esc +// with target "a/b/l/../../../x". That cleans to dst/x — apparently inside the +// destination — while the kernel walks it to three levels above the work +// directory, leaving an escaping symlink on disk for whatever reads that tree +// without os.Root. +func (s *sink) resolveLinkTarget(linkDir, linkTarget string) (string, error) { + cur := linkDir + pending := strings.Split(linkTarget, "/") + hops := 0 + + for len(pending) > 0 { + part := pending[0] + pending = pending[1:] + + switch part { + case "", ".": + continue + case "..": + if cur == "." { + return "", errors.New("symlink target escapes the work directory") + } + cur = path.Dir(cur) + + continue + } + + next := part + if cur != "." { + next = path.Join(cur, part) + } + + info, statErr := s.root.Lstat(next) + if statErr != nil || info.Mode()&os.ModeSymlink == 0 { + // Missing entries resolve literally: the archive may create them + // later, and a dangling link is not by itself an escape. + cur = next + + continue + } + + hops++ + if hops > maxSymlinkResolveHops { + return "", errors.New("symlink target crosses too many links") + } + + nested, linkErr := s.root.Readlink(next) + if linkErr != nil { + return "", errors.Wrapf(linkErr, "failed to read symlink %q", next) + } + if path.IsAbs(nested) { + return "", errors.New("symlink target crosses an absolute symlink") + } + + // The kernel replaces the link with its target resolved from the + // directory holding it, so cur stays put and the target is spliced in + // front of what is left to walk. + pending = append(strings.Split(nested, "/"), pending...) + } + + return cur, nil +} + +// checkLinkTarget reports whether the symlink stored at linkPath with the given +// literal target stays inside the destination. +// +// The directory holding the link is resolved first: os.Root creates the link in +// the directory linkPath resolves to, so a parent component that is itself a +// symlink moves the link — and with it what every ".." in its target pops off. +// An archive storing "s -> ." and then "s/l -> ../x" has os.Root put l next to +// s instead of below it, which turns a target the lexical path says is inside +// the destination into one that leaves it. +func (s *sink) checkLinkTarget(linkPath, linkTarget string) error { + if path.IsAbs(linkTarget) { + return errors.New("absolute symlink target") + } + + linkDir, err := s.resolveLinkTarget(".", path.Dir(linkPath)) + if err != nil { + return err + } + + resolved, err := s.resolveLinkTarget(linkDir, linkTarget) + if err != nil { + return err + } + if !s.withinDest(resolved) { + return errors.New("symlink target escapes the destination") + } + + return nil +} + +// revalidateLinks checks every symlink this run created once more, against the +// finished tree. +// +// checkLinkTarget can only see the tree as it stands when the entry arrives. A +// later entry can turn a component an earlier target resolved through into a +// symlink of its own — either by being stored after it or by replacing a +// directory under the overwrite policy — so two links that are each confined on +// their own combine into one that is not. +// +// A link that fails invalidates the whole run, so every link it created is +// removed: the checks are order dependent, and dropping only the offenders +// would leave the links validated before them resting on a tree that changed +// underneath. +func (s *sink) revalidateLinks() error { + for _, l := range s.links { + // A later entry may have taken the link away again (an overwritten + // directory takes its whole subtree with it); whatever sits there now + // belongs to that entry, not to this one. + info, err := s.root.Lstat(l.path) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + continue + } + + if err := s.checkLinkTarget(l.path, l.target); err != nil { + s.removeCreatedLinks() + + return errors.Wrapf(err, "symlink %q", l.path) + } + } + + return nil +} + +func (s *sink) removeCreatedLinks() { + for _, l := range s.links { + if info, err := s.root.Lstat(l.path); err != nil || info.Mode()&os.ModeSymlink == 0 { + continue + } + + _ = s.root.Remove(l.path) + } +} + +func (s *sink) putSymlink(name, linkTarget string) error { + target, ok, err := s.safeEntryName(name) + if err != nil { + return err + } + if !ok { + return nil + } + + if err := s.checkLinkTarget(target, linkTarget); err != nil { + return errors.Wrapf(err, "archive entry %q", name) + } + + skip, _, err := s.resolveConflict(target, false) + if err != nil { + return err + } + if skip { + s.skipped = append(s.skipped, name) + + return nil + } + + if err := s.ensureParent(target); err != nil { + return err + } + + if err := s.root.Symlink(linkTarget, target); err != nil { + return errors.Wrapf(err, "failed to create symlink %q", target) + } + + s.links = append(s.links, createdLink{path: target, target: linkTarget}) + + if err := osowner.ApplyToPathInRoot(s.root, target, s.owner); err != nil { + return errors.Wrapf(err, "failed to apply owner to %q", target) + } + + return s.acc.addEntry(name, 0) +} + +func (s *sink) ensureParent(target string) error { + parent := path.Dir(target) + if parent == "." || parent == "/" { + return nil + } + + return mkdirAllOwned(s.root, parent, defaultDirPerm, s.owner) +} + +// mkdirAllOwned creates rel and applies the owner to exactly the directories it +// had to create, leaving pre-existing (often shared) parents alone. Without +// this, a game server running as an unprivileged su_user cannot traverse into +// the tree the daemon just unpacked for it. +func mkdirAllOwned(root *os.Root, rel string, perm os.FileMode, owner osowner.Options) error { + created, err := osowner.MissingSegmentsInRoot(root, rel) + if err != nil { + return errors.Wrapf(err, "failed to inspect directory %q", rel) + } + + if err := root.MkdirAll(rel, perm); err != nil { + return errors.Wrapf(err, "failed to create directory %q", rel) + } + + for _, segment := range created { + if err := osowner.ApplyToPathInRoot(root, segment, owner); err != nil { + return errors.Wrapf(err, "failed to apply owner to %q", segment) + } + } + + return nil +} + +// Extract unpacks archive_path into destination. See the package doc for the +// confinement model and safeEntryName for the zip-slip rules. +func Extract(ctx context.Context, workDir string, p *pb.ExtractArchiveParams, progress ProgressFunc) (*Result, error) { + if err := ctx.Err(); err != nil { + return nil, errors.Wrap(err, "extract archive canceled") + } + + root, err := os.OpenRoot(workDir) + if err != nil { + return nil, errors.Wrap(err, "work directory unavailable") + } + defer root.Close() + + archiveRel, err := fsutil.RootRel(p.GetArchivePath()) + if err != nil { + return nil, err + } + + destRel, err := fsutil.RootRel(p.GetDestination()) + if err != nil { + return nil, err + } + + if err := prepareDestination(root, destRel, p); err != nil { + return nil, err + } + + archiveFile, err := root.Open(archiveRel) + if err != nil { + return nil, errors.Wrapf(err, "failed to open archive %q", p.GetArchivePath()) + } + defer archiveFile.Close() + + archiveInfo, err := archiveFile.Stat() + if err != nil { + return nil, errors.Wrapf(err, "failed to stat archive %q", p.GetArchivePath()) + } + + // The proto lets the request leave the format unset and expects the daemon + // to work it out from the content or the file name. + format := p.GetFormat() + if format == pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + if format, err = detectFormat(archiveFile, archiveRel); err != nil { + return nil, err + } + } + + class, err := classify(format) + if err != nil { + return nil, err + } + + s := &sink{ + root: root, + dest: destRel, + policy: p.GetConflictPolicy(), + preserve: p.GetPreservePermissions(), + mode: os.FileMode(p.GetMode()).Perm(), + owner: ownerOptions(p.GetOwnerUser(), p.GetOwnerUid(), p.GetOwnerGid()), + acc: newAccumulator(p.GetMaxTotalBytes(), p.GetMaxFiles(), progress), + } + + extractErr := extractEntries(ctx, archiveFile, archiveRel, class, format, s) + + // Symlink confinement is only settled once the archive can no longer move + // anything, so it is decided here — including for a run that failed, which + // leaves its partial tree behind and must not leave an escaping link in it. + linkErr := s.revalidateLinks() + + if extractErr != nil { + return nil, extractErr + } + if linkErr != nil { + return nil, linkErr + } + + return &Result{ + FilesProcessed: s.acc.files, + BytesProcessed: s.acc.bytes, + // The proto defines archive_size as the source archive when extracting. + ArchiveSize: archiveInfo.Size(), + Skipped: s.skipped, + Format: format, + }, nil +} + +func prepareDestination(root *os.Root, destRel string, p *pb.ExtractArchiveParams) error { + info, err := root.Stat(destRel) + switch { + case err == nil: + if !info.IsDir() { + return errors.Errorf("destination %q is not a directory", p.GetDestination()) + } + + return nil + case errors.Is(err, os.ErrNotExist): + if !p.GetCreateDestination() { + return errors.Errorf( + "destination %q does not exist and create_destination is disabled", p.GetDestination(), + ) + } + + owner := ownerOptions(p.GetOwnerUser(), p.GetOwnerUid(), p.GetOwnerGid()) + if err := mkdirAllOwned(root, destRel, defaultDirPerm, owner); err != nil { + return errors.Wrapf(err, "failed to create destination %q", p.GetDestination()) + } + + return nil + default: + return errors.Wrapf(err, "failed to stat destination %q", p.GetDestination()) + } +} diff --git a/internal/app/archive/extract_formats.go b/internal/app/archive/extract_formats.go new file mode 100644 index 0000000..5e982f6 --- /dev/null +++ b/internal/app/archive/extract_formats.go @@ -0,0 +1,330 @@ +package archive + +import ( + "archive/tar" + "archive/zip" + "context" + "io" + "os" + "path" + + "github.com/bodgit/sevenzip" + rardecode "github.com/nwaples/rardecode/v2" + "github.com/pkg/errors" + + pb "github.com/gameap/gameap/pkg/proto" +) + +// maxLinkTargetBytes caps how much is read for a symlink entry body; real +// link targets are a few hundred bytes at most. +const maxLinkTargetBytes = 1 << 20 + +// maxRarDictBytes caps the LZ window rardecode allocates up front from the +// archive header. Its own default is 4 GiB, which a crafted RAR can demand in +// a single allocation; WinRAR tops out at 32 MiB for the presets that produce +// real-world archives, so this leaves generous headroom. +const maxRarDictBytes = 256 << 20 + +func extractEntries( + ctx context.Context, + archiveFile *os.File, + archiveRel string, + class formatClass, + format pb.ArchiveFormat, + s *sink, +) error { + switch class { + case classZip: + return extractZip(ctx, archiveFile, s) + case classTar: + return extractTar(ctx, archiveFile, tarCompression(format), s) + case classSingle: + return extractSingle(archiveFile, archiveRel, format, s) + case class7z: + return extract7z(ctx, archiveFile, s) + default: + return extractRar(ctx, archiveFile, s) + } +} + +func extractZip(ctx context.Context, archiveFile *os.File, s *sink) error { + info, err := archiveFile.Stat() + if err != nil { + return errors.Wrap(err, "failed to stat archive") + } + + zr, err := zip.NewReader(archiveFile, info.Size()) + if err != nil { + return errors.Wrap(err, "failed to read zip archive") + } + + if err := s.acc.checkEntryCount(len(zr.File)); err != nil { + return err + } + + for _, f := range zr.File { + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "extract archive canceled") + } + + switch mode := f.Mode(); { + case f.FileInfo().IsDir(): + if err := s.putDir(f.Name, mode); err != nil { + return err + } + case mode&os.ModeSymlink != 0: + if err := extractZipSymlink(f, s); err != nil { + return err + } + default: + rc, err := f.Open() + if err != nil { + return errors.Wrapf(err, "failed to open zip entry %q", f.Name) + } + + err = s.putFile(f.Name, mode, rc) + _ = rc.Close() + + if err != nil { + return err + } + } + } + + return nil +} + +// putSymlinkFrom materializes a symlink whose target is stored as the entry +// body — the convention zip, 7z and RAR4 share. +func putSymlinkFrom(r io.Reader, name string, s *sink) error { + // One byte past the cap, so an oversized target is reported instead of + // silently becoming a truncated link. + target, err := io.ReadAll(io.LimitReader(r, maxLinkTargetBytes+1)) + if err != nil { + return errors.Wrapf(err, "failed to read symlink entry %q", name) + } + if len(target) > maxLinkTargetBytes { + return errors.Errorf("symlink entry %q exceeds the %d byte target limit", name, maxLinkTargetBytes) + } + + return s.putSymlink(name, string(target)) +} + +func extractZipSymlink(f *zip.File, s *sink) error { + rc, err := f.Open() + if err != nil { + return errors.Wrapf(err, "failed to open zip entry %q", f.Name) + } + defer rc.Close() + + return putSymlinkFrom(rc, f.Name, s) +} + +func extractTar(ctx context.Context, archiveFile *os.File, comp compression, s *sink) error { + stream, err := decompressReader(archiveFile, comp) + if err != nil { + return err + } + defer stream.Close() + + tr := tar.NewReader(stream) + + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return errors.Wrap(err, "failed to read tar archive") + } + + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "extract archive canceled") + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := s.putDir(hdr.Name, hdr.FileInfo().Mode()); err != nil { + return err + } + case tar.TypeSymlink: + if err := s.putSymlink(hdr.Name, hdr.Linkname); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := s.putFile(hdr.Name, hdr.FileInfo().Mode(), tr); err != nil { + return err + } + case tar.TypeLink: + if err := extractTarHardlink(hdr, s); err != nil { + return err + } + default: + // Fifos, device nodes and pax/gnu metadata records are skipped. + } + } +} + +// extractTarHardlink materializes a hardlink entry as a copy of the already +// extracted link target. +func extractTarHardlink(hdr *tar.Header, s *sink) error { + target, ok, err := s.safeEntryName(hdr.Linkname) + if err != nil { + return err + } + if !ok { + return errors.Errorf("hardlink entry %q has an empty target", hdr.Name) + } + + src, err := s.root.Open(target) + if err != nil { + return errors.Wrapf(err, "failed to open hardlink target %q", hdr.Linkname) + } + defer src.Close() + + return s.putFile(hdr.Name, hdr.FileInfo().Mode(), src) +} + +// extractSingle handles the gz/bz2/xz/zstd formats: the whole stream is one +// file, named after the archive minus its compression suffix. +func extractSingle(archiveFile *os.File, archiveRel string, format pb.ArchiveFormat, s *sink) error { + stream, err := decompressReader(archiveFile, singleCompression(format)) + if err != nil { + return err + } + defer stream.Close() + + name := singleOutputName(path.Base(archiveRel), format) + + return s.putFile(name, 0, stream) +} + +func extract7z(ctx context.Context, archiveFile *os.File, s *sink) error { + info, err := archiveFile.Stat() + if err != nil { + return errors.Wrap(err, "failed to stat archive") + } + + zr, err := sevenzip.NewReader(archiveFile, info.Size()) + if err != nil { + return wrapArchiveReadErr(err, "7z") + } + + if err := s.acc.checkEntryCount(len(zr.File)); err != nil { + return err + } + + for _, f := range zr.File { + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "extract archive canceled") + } + + if f.FileInfo().IsDir() { + if err := s.putDir(f.Name, f.Mode()); err != nil { + return err + } + + continue + } + + if err := extract7zEntry(f, s); err != nil { + // A 7z archive can keep its header readable while only the entry + // data is encrypted, so "password required" first surfaces here and + // still has to reach the API as ErrArchiveEncrypted. + if encrypted7z(err) { + return errors.Wrap(ErrArchiveEncrypted, "7z archive") + } + + return err + } + } + + return nil +} + +func extract7zEntry(f *sevenzip.File, s *sink) error { + rc, err := f.Open() + if err != nil { + return errors.Wrapf(err, "failed to open 7z entry %q", f.Name) + } + defer rc.Close() + + // Like zip, a unix symlink is stored as an entry whose body is the link + // target. + if f.Mode()&os.ModeSymlink != 0 { + return putSymlinkFrom(rc, f.Name, s) + } + + return s.putFile(f.Name, f.Mode(), rc) +} + +func extractRar(ctx context.Context, archiveFile *os.File, s *sink) error { + rr, err := rardecode.NewReader(archiveFile, rardecode.MaxDictionarySize(maxRarDictBytes)) + if err != nil { + return wrapArchiveReadErr(err, "rar") + } + + for { + hdr, err := rr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return wrapArchiveReadErr(err, "rar") + } + + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "extract archive canceled") + } + + if hdr.IsDir { + if err := s.putDir(hdr.Name, hdr.Mode()); err != nil { + return err + } + + continue + } + + if hdr.Mode()&os.ModeSymlink != 0 { + if err := extractRarSymlink(hdr, rr, s); err != nil { + return err + } + + continue + } + + // The rar reader is sequential: putFile consumes the current entry + // body (or drains it when the entry is skipped). + if err := s.putFile(hdr.Name, hdr.Mode(), rr); err != nil { + return err + } + } +} + +// extractRarSymlink materializes a RAR4 unix symlink entry. RAR5 redirection +// records are not exposed by rardecode, so those symlinks are still extracted +// as files. +func extractRarSymlink(hdr *rardecode.FileHeader, rr io.Reader, s *sink) error { + return putSymlinkFrom(rr, hdr.Name, s) +} + +// wrapArchiveReadErr turns a decoder failure into the operation error. Both +// decoders can tell "this archive is encrypted" apart from "this archive is +// broken", and the API needs that distinction to prompt for a password +// instead of showing a generic read failure. +func wrapArchiveReadErr(err error, format string) error { + if errors.Is(err, rardecode.ErrArchiveEncrypted) || encrypted7z(err) { + return errors.Wrapf(ErrArchiveEncrypted, "%s archive", format) + } + + return errors.Wrapf(err, "failed to read %s archive", format) +} + +// encrypted7z reports a sevenzip failure caused by missing decryption. The +// decoder hands its read errors out as *ReadError, so the target has to be the +// pointer type — matching the value never fires. +func encrypted7z(err error) bool { + var readErr *sevenzip.ReadError + + return errors.As(err, &readErr) && readErr.Encrypted +} diff --git a/internal/app/archive/extract_test.go b/internal/app/archive/extract_test.go new file mode 100644 index 0000000..eb0ed30 --- /dev/null +++ b/internal/app/archive/extract_test.go @@ -0,0 +1,701 @@ +package archive + +import ( + "archive/zip" + "bytes" + "context" + "encoding/binary" + "hash/crc32" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pb "github.com/gameap/gameap/pkg/proto" +) + +func TestExtract7zFixture(t *testing.T) { + workDir := t.TempDir() + copyFixture(t, workDir, "test.7z") + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "test.7z", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_7Z, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, int64(2), res.FilesProcessed) + + assert.Equal(t, map[string]string{ + "bar": "bar\n", + "foo": "foo\n", + }, readTree(t, filepath.Join(workDir, "dst"))) +} + +func TestExtractRarFixture(t *testing.T) { + workDir := t.TempDir() + copyFixture(t, workDir, "test.rar") + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "test.rar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_RAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, int64(2), res.FilesProcessed) + + assert.Equal(t, map[string]string{ + "hello.txt": "Hello, RAR!\n", + "subdir/nested.txt": "nested rar content\n", + }, readTree(t, filepath.Join(workDir, "dst"))) +} + +// buildZip writes a zip archive with the given raw entry names (no +// sanitization, so malicious names can be constructed). +func buildZip(t *testing.T, path string, entries map[string]string) { + t.Helper() + + f, err := os.Create(path) + require.NoError(t, err) + + zw := zip.NewWriter(f) + for name, content := range entries { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write([]byte(content)) + require.NoError(t, err) + } + + require.NoError(t, zw.Close()) + require.NoError(t, f.Close()) +} + +// zipEntry is one entry for buildZipModes, carrying a full os.FileMode so +// symlink and directory entries can be constructed. +type zipEntry struct { + name string + mode os.FileMode + body string +} + +// buildZipModes writes a zip preserving entry order, so entries that depend on +// earlier ones (a symlink into a directory unpacked before it) behave the way +// they would in a real archive. +func buildZipModes(t *testing.T, path string, entries []zipEntry) { + t.Helper() + + f, err := os.Create(path) + require.NoError(t, err) + + zw := zip.NewWriter(f) + for _, e := range entries { + hdr := &zip.FileHeader{Name: e.name, Method: zip.Store} + hdr.SetMode(e.mode) + + w, createErr := zw.CreateHeader(hdr) + require.NoError(t, createErr) + _, writeErr := w.Write([]byte(e.body)) + require.NoError(t, writeErr) + } + + require.NoError(t, zw.Close()) + require.NoError(t, f.Close()) +} + +// TestExtractSymlinkChainEscape covers a target that a purely lexical check +// accepts: "a/b/l/../../../x" cleans to "dst/x", but "a/b/l" is itself a link +// to "../..", so the kernel walks the path out of the work directory. +func TestExtractSymlinkChainEscape(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "chain.zip"), []zipEntry{ + {name: "a/b/", mode: os.ModeDir | 0o755}, + {name: "a/b/l", mode: os.ModeSymlink | 0o777, body: "../.."}, + {name: "esc", mode: os.ModeSymlink | 0o777, body: "a/b/l/../../../x"}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "chain.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), `"esc"`) + + link, readErr := os.Readlink(filepath.Join(workDir, "dst", "esc")) + assert.Error(t, readErr, "escaping symlink must not be created, points at %q", link) +} + +// TestExtractSymlinkChainWithinDestination is the counterpart: the same kind of +// chain must keep working as long as it stays inside the destination. +func TestExtractSymlinkChainWithinDestination(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "chain.zip"), []zipEntry{ + {name: "a/b/", mode: os.ModeDir | 0o755}, + {name: "a/b/l", mode: os.ModeSymlink | 0o777, body: "../.."}, + {name: "ok", mode: os.ModeSymlink | 0o777, body: "a/b/l/payload.txt"}, + {name: "payload.txt", mode: 0o644, body: "payload"}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "chain.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(workDir, "dst", "ok")) + require.NoError(t, err) + assert.Equal(t, "payload", string(content), "a chain resolving inside the destination must still work") +} + +// TestExtractSymlinkEscapeThroughLaterEntry covers the same escape built the +// other way round: "esc" is stored while "a/b" is still missing, so its target +// resolves literally to "dst/c", and only the entry after it turns "a/b" into +// the link that walks the finished tree out of the work directory. +func TestExtractSymlinkEscapeThroughLaterEntry(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "chain.zip"), []zipEntry{ + {name: "esc", mode: os.ModeSymlink | 0o777, body: "a/b/../../c"}, + {name: "a/b", mode: os.ModeSymlink | 0o777, body: ".."}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "chain.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.Error(t, err) + + link, readErr := os.Readlink(filepath.Join(workDir, "dst", "esc")) + assert.Error(t, readErr, "escaping symlink must not survive the run, points at %q", link) +} + +// TestExtractSymlinkEscapeThroughOverwrittenDirectory is the overwrite variant: +// "a/b" is a directory when "esc" is checked against it and a symlink by the +// time the run ends. +func TestExtractSymlinkEscapeThroughOverwrittenDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "chain.zip"), []zipEntry{ + {name: "a/b/", mode: os.ModeDir | 0o755}, + {name: "esc", mode: os.ModeSymlink | 0o777, body: "a/b/../../c"}, + {name: "a/b", mode: os.ModeSymlink | 0o777, body: ".."}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "chain.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + ConflictPolicy: pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_OVERWRITE, + }, nil) + require.Error(t, err) + + link, readErr := os.Readlink(filepath.Join(workDir, "dst", "esc")) + assert.Error(t, readErr, "escaping symlink must not survive the run, points at %q", link) +} + +// TestExtractSymlinkEscapeThroughSymlinkedParent covers the link that is not +// stored where its name says: "s1" and "s2" both resolve back to the +// destination, so os.Root puts "l" directly under it and its "../.." reaches +// above the work directory, however deep the entry name looks. +func TestExtractSymlinkEscapeThroughSymlinkedParent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "parent.zip"), []zipEntry{ + {name: "s1", mode: os.ModeSymlink | 0o777, body: "."}, + {name: "s1/s2", mode: os.ModeSymlink | 0o777, body: "."}, + {name: "s1/s2/l", mode: os.ModeSymlink | 0o777, body: "../../y"}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "parent.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), `"s1/s2/l"`) + + link, readErr := os.Readlink(filepath.Join(workDir, "dst", "l")) + assert.Error(t, readErr, "escaping symlink must not be created, points at %q", link) +} + +// TestExtractOversizedSymlinkTarget pins that a target past the cap is reported +// instead of silently truncated into a link pointing somewhere else entirely. +func TestExtractOversizedSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "big.zip"), []zipEntry{ + {name: "link", mode: os.ModeSymlink | 0o777, body: strings.Repeat("a", maxLinkTargetBytes+1)}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "big.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "target limit") +} + +func TestExtractCorruptedArchives(t *testing.T) { + for _, tc := range []struct { + name string + fixture string + format pb.ArchiveFormat + }{ + {"7z", "test.7z", pb.ArchiveFormat_ARCHIVE_FORMAT_7Z}, + {"rar", "test.rar", pb.ArchiveFormat_ARCHIVE_FORMAT_RAR}, + } { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + copyFixture(t, workDir, tc.fixture) + + p := filepath.Join(workDir, tc.fixture) + data, err := os.ReadFile(p) + require.NoError(t, err) + + // Keep the signature so the decoder commits to parsing, then feed + // it garbage: this must surface as an error, never a panic. + for i := 8; i < len(data); i++ { + data[i] ^= 0xFF + } + require.NoError(t, os.WriteFile(p, data, 0o644)) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: tc.fixture, + Destination: "dst", + Format: tc.format, + CreateDestination: true, + }, nil) + require.Error(t, err) + }) + } +} + +// rar4Entry is one stored (uncompressed) RAR4 file entry; attr is the unix +// st_mode value reported in the header (host OS is always unix here). +type rar4Entry struct { + name string + attr uint32 + data []byte +} + +// buildRar4 writes a minimal RAR4 archive with stored entries: marker block, +// main header, one file header per entry and an end-of-archive block. There +// is no RAR encoder in the module dependencies, so the bytes are assembled +// by hand following the layout rardecode parses. +func buildRar4(t *testing.T, path string, entries []rar4Entry) { + t.Helper() + + var buf bytes.Buffer + buf.Write([]byte{0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00}) // RAR4 marker block + writeRar4Block(&buf, 0x73, 0, make([]byte, 6)) // main archive header + + for _, e := range entries { + hdr := make([]byte, 0, 25+len(e.name)) + hdr = binary.LittleEndian.AppendUint32(hdr, uint32(len(e.data))) // packed size + hdr = binary.LittleEndian.AppendUint32(hdr, uint32(len(e.data))) // unpacked size + hdr = append(hdr, 3) // host OS: unix + hdr = binary.LittleEndian.AppendUint32(hdr, crc32.ChecksumIEEE(e.data)) + hdr = binary.LittleEndian.AppendUint32(hdr, 0) // modification time (dos format) + hdr = append(hdr, 20) // minimum rar version to extract + hdr = append(hdr, 0x30) // method: store + hdr = binary.LittleEndian.AppendUint16(hdr, uint16(len(e.name))) + hdr = binary.LittleEndian.AppendUint32(hdr, e.attr) + hdr = append(hdr, e.name...) + + writeRar4Block(&buf, 0x74, 0x8000, hdr) // 0x8000: entry data follows the header + buf.Write(e.data) + } + + writeRar4Block(&buf, 0x7B, 0, nil) // end of archive + + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644)) +} + +// writeRar4Block appends one RAR4 block: crc16 (low bits of the CRC32 over +// type..end), type, flags, header size and the header body. +func writeRar4Block(buf *bytes.Buffer, btype byte, flags uint16, data []byte) { + body := make([]byte, 0, 5+len(data)) + body = append(body, btype) + body = binary.LittleEndian.AppendUint16(body, flags) + body = binary.LittleEndian.AppendUint16(body, uint16(7+len(data))) + body = append(body, data...) + + buf.Write(binary.LittleEndian.AppendUint16(nil, uint16(crc32.ChecksumIEEE(body)))) + buf.Write(body) +} + +func TestExtractRarSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildRar4(t, filepath.Join(workDir, "links.rar"), []rar4Entry{ + {name: "target.txt", attr: 0x81A4, data: []byte("linked content\n")}, // regular 0644 + {name: "link.txt", attr: 0xA1FF, data: []byte("target.txt")}, // symlink 0777 + }) + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "links.rar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_RAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, int64(2), res.FilesProcessed) + + link, err := os.Readlink(filepath.Join(workDir, "dst", "link.txt")) + require.NoError(t, err) + assert.Equal(t, "target.txt", link, "rar symlink entry must be extracted as a symlink, not a regular file") + + content, err := os.ReadFile(filepath.Join(workDir, "dst", "target.txt")) + require.NoError(t, err) + assert.Equal(t, "linked content\n", string(content)) +} + +func TestExtractZipSlip(t *testing.T) { + t.Run("dotdot entry", func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(workDir, "dst"), 0o755)) + buildZip(t, filepath.Join(workDir, "evil.zip"), map[string]string{"../evil.txt": "pwned"}) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "evil.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "escapes the destination") + + _, statErr := os.Stat(filepath.Join(workDir, "evil.txt")) + assert.True(t, os.IsNotExist(statErr), "zip-slip file must not be created outside the destination") + }) + + t.Run("absolute entry", func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(workDir, "dst"), 0o755)) + buildZip(t, filepath.Join(workDir, "evil.zip"), map[string]string{"/abs.txt": "pwned"}) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "evil.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "escapes the destination") + }) + + t.Run("backslash dotdot entry", func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(workDir, "dst"), 0o755)) + buildZip(t, filepath.Join(workDir, "evil.zip"), map[string]string{`..\evil.txt`: "pwned"}) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "evil.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "escapes the destination") + }) +} + +func TestExtractConflictPolicy(t *testing.T) { + buildArchive := func(t *testing.T, workDir string) { + t.Helper() + writeTree(t, workDir, map[string]string{"src/exists.txt": "new", "src/fresh.txt": "fresh"}) + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"src"}, + }, nil) + require.NoError(t, err) + } + + prepareDst := func(t *testing.T, workDir string) { + t.Helper() + writeTree(t, workDir, map[string]string{"dst/src/exists.txt": "old"}) + } + + extract := func(t *testing.T, workDir string, policy pb.ArchiveConflictPolicy) (*Result, error) { + t.Helper() + + return Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + ConflictPolicy: policy, + }, nil) + } + + conflictingContent := func(t *testing.T, workDir string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join(workDir, "dst", "src", "exists.txt")) + require.NoError(t, err) + + return string(content) + } + + t.Run("error is the default", func(t *testing.T) { + workDir := t.TempDir() + buildArchive(t, workDir) + prepareDst(t, workDir) + + _, err := extract(t, workDir, pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_UNSPECIFIED) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + assert.Equal(t, "old", conflictingContent(t, workDir)) + }) + + t.Run("error policy", func(t *testing.T) { + workDir := t.TempDir() + buildArchive(t, workDir) + prepareDst(t, workDir) + + _, err := extract(t, workDir, pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_ERROR) + require.Error(t, err) + assert.Equal(t, "old", conflictingContent(t, workDir)) + }) + + t.Run("skip policy", func(t *testing.T) { + workDir := t.TempDir() + buildArchive(t, workDir) + prepareDst(t, workDir) + + res, err := extract(t, workDir, pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_SKIP) + require.NoError(t, err) + assert.Equal(t, []string{"src/exists.txt"}, res.Skipped) + assert.Equal(t, "old", conflictingContent(t, workDir)) + assert.Equal(t, "fresh", readTree(t, filepath.Join(workDir, "dst"))["src/fresh.txt"], + "non-conflicting entries must still be extracted") + }) + + t.Run("overwrite policy", func(t *testing.T) { + workDir := t.TempDir() + buildArchive(t, workDir) + prepareDst(t, workDir) + + res, err := extract(t, workDir, pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_OVERWRITE) + require.NoError(t, err) + assert.Empty(t, res.Skipped) + assert.Equal(t, "new", conflictingContent(t, workDir)) + }) +} + +func TestExtractLimits(t *testing.T) { + setup := func(t *testing.T, workDir string) { + t.Helper() + writeTree(t, workDir, map[string]string{"src/a.txt": "aaaa", "src/b.txt": "bbbb"}) + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"src"}, + }, nil) + require.NoError(t, err) + } + + t.Run("max total bytes", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + MaxTotalBytes: 1, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max total bytes limit exceeded") + }) + + t.Run("max files", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + MaxFiles: 1, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max files limit exceeded") + }) +} + +func TestExtractDestination(t *testing.T) { + setup := func(t *testing.T, workDir string) { + t.Helper() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + }, nil) + require.NoError(t, err) + } + + t.Run("missing destination without create flag", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "missing", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") + }) + + t.Run("missing destination with create flag", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "missing/nested", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, map[string]string{"a.txt": "a"}, readTree(t, filepath.Join(workDir, "missing", "nested"))) + }) + + t.Run("destination is a file", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "a.txt", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") + }) + + t.Run("unspecified format is detected", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, res.Format) + assert.Equal(t, map[string]string{"a.txt": "a"}, readTree(t, filepath.Join(workDir, "dst"))) + }) +} + +func TestExtractModeOverride(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission bits are not supported on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"a.txt"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + Mode: 0o600, + }, nil) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(workDir, "dst", "a.txt")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "mode must override archive permissions") +} + +func TestExtractPreservePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission bits are not supported on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"script.sh": "#!/bin/sh\n"}) + require.NoError(t, os.Chmod(filepath.Join(workDir, "script.sh"), 0o750)) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"script.sh"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + PreservePermissions: true, + }, nil) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(workDir, "dst", "script.sh")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o750), info.Mode().Perm(), "archive permissions must be preserved") +} + +func TestExtractCanceledContext(t *testing.T) { + workDir := t.TempDir() + copyFixture(t, workDir, "test.7z") + + _, err := Extract(canceledContext(t), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "test.7z", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_7Z, + CreateDestination: true, + }, nil) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} diff --git a/internal/app/archive/format.go b/internal/app/archive/format.go new file mode 100644 index 0000000..10e6422 --- /dev/null +++ b/internal/app/archive/format.go @@ -0,0 +1,195 @@ +package archive + +import ( + "compress/gzip" + "strings" + + "github.com/pkg/errors" + + dsbzip2 "github.com/dsnet/compress/bzip2" + pb "github.com/gameap/gameap/pkg/proto" + "github.com/klauspost/compress/zstd" +) + +// formatClass groups archive formats by the code path that handles them. +type formatClass int + +const ( + classZip formatClass = iota + 1 + classTar // plain tar and tar wrapped into a compressor stream + classSingle // gz/bz2/xz/zstd compressing one bare file + class7z + classRar +) + +// compression identifies the stream compressor wrapped around a tar stream or +// used for a single-file format. +type compression int + +const ( + compNone compression = iota + compGzip + compBzip2 + compXz + compZstd +) + +func classify(format pb.ArchiveFormat) (formatClass, error) { + switch format { + case pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP: + return classZip, nil + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ, + pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2, + pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ, + pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD: + return classTar, nil + case pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2, + pb.ArchiveFormat_ARCHIVE_FORMAT_XZ, + pb.ArchiveFormat_ARCHIVE_FORMAT_ZSTD: + return classSingle, nil + case pb.ArchiveFormat_ARCHIVE_FORMAT_7Z: + return class7z, nil + case pb.ArchiveFormat_ARCHIVE_FORMAT_RAR: + return classRar, nil + default: + return 0, errors.Errorf("unsupported archive format: %s", format) + } +} + +// classifyForCreate rejects formats the daemon cannot write. +func classifyForCreate(format pb.ArchiveFormat) (formatClass, error) { + if format == pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + return 0, errors.New("archive format is unspecified") + } + + class, err := classify(format) + if err != nil { + return 0, err + } + + if class == class7z || class == classRar { + return 0, errors.Errorf("archive format %s is extract-only", format) + } + + return class, nil +} + +func tarCompression(format pb.ArchiveFormat) compression { + switch format { + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ: + return compGzip + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2: + return compBzip2 + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ: + return compXz + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD: + return compZstd + default: + return compNone + } +} + +func singleCompression(format pb.ArchiveFormat) compression { + switch format { + case pb.ArchiveFormat_ARCHIVE_FORMAT_GZ: + return compGzip + case pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2: + return compBzip2 + case pb.ArchiveFormat_ARCHIVE_FORMAT_XZ: + return compXz + default: + return compZstd + } +} + +// singleSuffix maps a single-file format to its conventional file suffix. +func singleSuffix(format pb.ArchiveFormat) string { + switch format { + case pb.ArchiveFormat_ARCHIVE_FORMAT_GZ: + return ".gz" + case pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2: + return ".bz2" + case pb.ArchiveFormat_ARCHIVE_FORMAT_XZ: + return ".xz" + default: + return ".zst" + } +} + +// singleOutputName derives the extracted file name for a single-file format: +// the archive base name minus its compression suffix, or ".out" when +// the archive name carries no known suffix. +func singleOutputName(archiveName string, format pb.ArchiveFormat) string { + if base, ok := strings.CutSuffix(archiveName, singleSuffix(format)); ok && base != "" { + return base + } + + return archiveName + ".out" +} + +// gzipLevel maps the proto compression level onto compress/gzip levels: +// unset = format default, 0 = store (NoCompression), 1..9 passed through. +func gzipLevel(level *int32) int { + if level == nil { + return gzip.DefaultCompression + } + + return clampLevel(*level) +} + +// bzip2Level maps the proto compression level onto dsnet bzip2 levels. The +// format cannot store, so a store request degrades to the fastest level. +func bzip2Level(level *int32) int { + if level == nil { + return dsbzip2.DefaultCompression + } + if *level == 0 { + return dsbzip2.BestSpeed + } + + return clampLevel(*level) +} + +// zstdLevel maps the proto compression level onto klauspost zstd encoder +// levels. The format cannot store, so a store request degrades to the +// fastest level. +func zstdLevel(level *int32) zstd.EncoderLevel { + if level == nil { + return zstd.SpeedDefault + } + + switch { + case *level <= 3: + return zstd.SpeedFastest + case *level <= 6: + return zstd.SpeedDefault + case *level <= 8: + return zstd.SpeedBetterCompression + default: + return zstd.SpeedBestCompression + } +} + +// flateLevel maps the proto compression level onto compress/flate levels for +// zip deflate entries. xz is not mapped at all: the xz format has no +// compression levels (only dictionary presets), so the level is ignored there. +func flateLevel(level *int32) int { + if level == nil { + return gzip.DefaultCompression + } + + return clampLevel(*level) +} + +func clampLevel(level int32) int { + if level < 0 { + return 0 + } + if level > 9 { + return 9 + } + + return int(level) +} diff --git a/internal/app/archive/helpers_test.go b/internal/app/archive/helpers_test.go new file mode 100644 index 0000000..5974080 --- /dev/null +++ b/internal/app/archive/helpers_test.go @@ -0,0 +1,88 @@ +package archive + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// writeTree creates files (and their parent directories) under dir. Map keys +// are slash-separated relative paths. +func writeTree(t *testing.T, dir string, files map[string]string) { + t.Helper() + + for name, content := range files { + p := filepath.Join(dir, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte(content), 0o644)) + } +} + +// readTree reads every regular file under dir into a slash-separated +// relative-path map. +func readTree(t *testing.T, dir string) map[string]string { + t.Helper() + + files := map[string]string{} + + err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + + rel, err := filepath.Rel(dir, p) + if err != nil { + return err + } + + content, err := os.ReadFile(p) + if err != nil { + return err + } + + files[filepath.ToSlash(rel)] = string(content) + + return nil + }) + require.NoError(t, err) + + return files +} + +// copyFixture copies a repo fixture from test/files into the work directory. +func copyFixture(t *testing.T, workDir, name string) { + t.Helper() + + data, err := os.ReadFile(filepath.Join("..", "..", "..", "test", "files", name)) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(workDir, name), data, 0o644)) +} + +type progressRecord struct { + files int64 + bytes int64 + entries []string +} + +func (p *progressRecord) fn() ProgressFunc { + return func(filesProcessed, bytesProcessed int64, currentEntry string) { + p.files = filesProcessed + p.bytes = bytesProcessed + p.entries = append(p.entries, currentEntry) + } +} + +func canceledContext(t *testing.T) context.Context { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx +} diff --git a/internal/app/components/executor.go b/internal/app/components/executor.go index 1b02cd5..0a96d09 100644 --- a/internal/app/components/executor.go +++ b/internal/app/components/executor.go @@ -8,7 +8,6 @@ import ( "os/exec" "path/filepath" "strconv" - "strings" "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/pkg/shellquote" @@ -59,6 +58,31 @@ func (e *Executor) ExecWithWriter( return result, err } +func (e *Executor) ExecArgs( + ctx context.Context, args []string, options contracts.ExecutorOptions, +) ([]byte, int, error) { + return ExecArgs(ctx, args, options) +} + +func (e *Executor) ExecWithWriterArgs( + ctx context.Context, + args []string, + out io.Writer, + options contracts.ExecutorOptions, +) (int, error) { + if e.appendCommandAndExitCode { + _, _ = out.Write([]byte(fmt.Sprintf("%s# %s\n\n", options.WorkDir, shellquote.Join(args...)))) + } + + result, err := ExecWithWriterArgs(ctx, args, out, options) + + if e.appendCommandAndExitCode { + _, _ = out.Write([]byte("\nExited with " + strconv.Itoa(result) + "\n")) + } + + return result, err +} + func Exec(ctx context.Context, command string, options contracts.ExecutorOptions) ([]byte, int, error) { buf := NewSafeBuffer() exitCode, err := ExecWithWriter(ctx, command, buf, options) @@ -74,7 +98,6 @@ func Exec(ctx context.Context, command string, options contracts.ExecutorOptions return out, exitCode, nil } -//nolint:funlen func ExecWithWriter( ctx context.Context, command string, out io.Writer, options contracts.ExecutorOptions, ) (int, error) { @@ -87,8 +110,34 @@ func ExecWithWriter( return invalidResult, err } + return ExecWithWriterArgs(ctx, args, out, options) +} + +func ExecArgs(ctx context.Context, args []string, options contracts.ExecutorOptions) ([]byte, int, error) { + buf := NewSafeBuffer() + exitCode, err := ExecWithWriterArgs(ctx, args, buf, options) + if err != nil { + return nil, invalidResult, err + } + + out, err := io.ReadAll(buf) + if err != nil { + return nil, invalidResult, err + } + + return out, exitCode, nil +} + +//nolint:funlen +func ExecWithWriterArgs( + ctx context.Context, args []string, out io.Writer, options contracts.ExecutorOptions, +) (int, error) { + if len(args) == 0 { + return invalidResult, ErrEmptyCommand + } + workDir := options.WorkDir - _, err = os.Stat(workDir) + _, err := os.Stat(workDir) if err != nil && options.FallbackWorkDir == "" { return invalidResult, errors.Wrapf(err, "invalid work directory %s", workDir) } else if err != nil && options.FallbackWorkDir != "" { @@ -117,14 +166,7 @@ func ExecWithWriter( return invalidResult, errors.Wrap(err, "executable file not found") } - filteredArgs := make([]string, 0, len(args)) - for _, arg := range args[1:] { - if arg != "" { - filteredArgs = append(filteredArgs, strings.TrimSpace(arg)) - } - } - - cmd := exec.CommandContext(ctx, name, filteredArgs...) + cmd := exec.CommandContext(ctx, name, args[1:]...) cmd.Dir = workDir cmd.Stdout = out cmd.Stderr = out diff --git a/internal/app/components/extendable_executor.go b/internal/app/components/extendable_executor.go index 92c9d8f..1f97ff0 100644 --- a/internal/app/components/extendable_executor.go +++ b/internal/app/components/extendable_executor.go @@ -90,3 +90,46 @@ func (executor *ExtendableExecutor) ExecWithWriter( return handler(ctx, args[1:], out, options) } + +func (executor *ExtendableExecutor) ExecArgs( + ctx context.Context, + args []string, + options contracts.ExecutorOptions, +) ([]byte, int, error) { + buf := NewSafeBuffer() + + exitCode, err := executor.ExecWithWriterArgs(ctx, args, buf, options) + if err != nil { + return nil, exitCode, err + } + + out, err := io.ReadAll(buf) + if err != nil { + return nil, -1, err + } + + return out, exitCode, err +} + +func (executor *ExtendableExecutor) ExecWithWriterArgs( + ctx context.Context, + args []string, + out io.Writer, + options contracts.ExecutorOptions, +) (int, error) { + if len(args) == 0 { + return invalidResult, ErrInvalidCommand + } + + handleCommand := args[0] + + executor.mu.RLock() + handler, exists := executor.handlers[handleCommand] + executor.mu.RUnlock() + + if !exists { + return executor.innerExecutor.ExecWithWriterArgs(ctx, args, out, options) + } + + return handler(ctx, args[1:], out, options) +} diff --git a/internal/app/config/config.go b/internal/app/config/config.go index b1e7b65..242d379 100644 --- a/internal/app/config/config.go +++ b/internal/app/config/config.go @@ -36,7 +36,6 @@ type SteamConfig struct { } type GRPCConfig struct { - Enabled bool `yaml:"enabled"` Insecure bool `yaml:"insecure"` Address string `yaml:"address"` HeartbeatInterval time.Duration `yaml:"heartbeat_interval"` @@ -72,16 +71,11 @@ const ( type Config struct { NodeID uint `yaml:"ds_id"` - ListenIP string `yaml:"listen_ip"` - ListenPort int `yaml:"listen_port"` - + // APIHost is deprecated: it is kept only as a fallback source for the + // gRPC address (see GRPCAddress) and the insecure transport detection. APIHost string `yaml:"api_host"` APIKey string `yaml:"api_key"` - DaemonLogin string `yaml:"daemon_login"` - DaemonPassword string `yaml:"daemon_password"` - PasswordAuthentication bool `yaml:"password_authentication"` - CACertificateFile string `yaml:"ca_certificate_file"` CACertificate string `yaml:"ca_certificate"` CertificateChainFile string `yaml:"certificate_chain_file"` @@ -89,14 +83,10 @@ type Config struct { PrivateKeyFile string `yaml:"private_key_file"` PrivateKey string `yaml:"private_key"` PrivateKeyPassword string `yaml:"private_key_password"` - DHFile string `yaml:"dh_file"` IFList []string `yaml:"if_list"` DrivesList []string `yaml:"drives_list"` - StatsUpdatePeriod int `yaml:"stats_update_period"` - StatsDBUpdatePeriod int `yaml:"stats_db_update_period"` - // Log config LogLevel string `yaml:"log_level"` OutputLog string `yaml:"output_log"` @@ -112,10 +102,11 @@ type Config struct { SteamConfig SteamConfig `yaml:"steam_config"` + RemoteRepositoryReplacements RepositoryReplacements `yaml:"remote_repository_replacements"` + Scripts Scripts TaskManager struct { - UpdatePeriod time.Duration `yaml:"update_period"` RunTaskPeriod time.Duration `yaml:"run_task_period"` TaskTimeout time.Duration `yaml:"task_timeout"` WorkersCount int `yaml:"workers_count"` @@ -142,9 +133,6 @@ type Config struct { func NewConfig() *Config { return &Config{ - ListenIP: "0.0.0.0", - ListenPort: 31717, - LogLevel: "info", } } @@ -154,10 +142,6 @@ func (cfg *Config) Init() error { cfg.ToolsPath = filepath.Join(cfg.WorkPath, "tools") } - if cfg.TaskManager.UpdatePeriod == 0 { - cfg.TaskManager.UpdatePeriod = 1 * time.Second - } - if cfg.TaskManager.RunTaskPeriod == 0 { cfg.TaskManager.RunTaskPeriod = 10 * time.Millisecond } @@ -228,14 +212,16 @@ func (cfg *Config) validate() error { return err } - if !cfg.GRPC.Enabled { - if cfg.APIHost == "" { - return ErrEmptyAPIHost - } + if err := cfg.RemoteRepositoryReplacements.validate(); err != nil { + return err + } - if cfg.APIKey == "" { - return ErrEmptyAPIKey - } + if cfg.APIKey == "" { + return ErrEmptyAPIKey + } + + if cfg.GRPC.Address == "" && cfg.APIHost == "" { + return ErrNoGRPCAddress } if !cfg.IsInsecure() { @@ -312,8 +298,11 @@ func (cfg *Config) WorkDir() string { return cfg.WorkPath } +// IsInsecure reports whether the panel connection runs without TLS, either +// because it is configured explicitly or because the deprecated api_host +// carries an http:// scheme. Certificates are not validated in that case. func (cfg *Config) IsInsecure() bool { - return strings.HasPrefix(cfg.APIHost, "http://") + return cfg.GRPC.Insecure || strings.HasPrefix(cfg.APIHost, "http://") } func (cfg *Config) GRPCAddress() string { diff --git a/internal/app/config/config_test.go b/internal/app/config/config_test.go index 62bc80b..f5d3935 100644 --- a/internal/app/config/config_test.go +++ b/internal/app/config/config_test.go @@ -31,11 +31,12 @@ func TestValidate(t *testing.T) { ErrEmptyNodeID, }, { - "empty APIHost", + "no gRPC address sources", func(cfg *Config) { cfg.APIHost = "" + cfg.GRPC.Address = "" }, - ErrEmptyAPIHost, + ErrNoGRPCAddress, }, { "empty APIKey", @@ -111,6 +112,16 @@ func TestValidate_SystemDScope_AcceptsValidValues(t *testing.T) { } } +func TestValidate_GRPCAddressWithoutAPIHost(t *testing.T) { + cfg := givenValidConfig(t) + cfg.APIHost = "" + cfg.GRPC.Address = "panel.example.com:31718" + + err := cfg.Init() + + assert.NoError(t, err) +} + func TestValidate_InsecureWithoutCerts(t *testing.T) { cfg := NewConfig() cfg.NodeID = 1 @@ -187,26 +198,64 @@ func TestPrivateKeyPEM_Inline(t *testing.T) { assert.Equal(t, []byte("inline-key-pem"), pem) } -func TestIsInsecure(t *testing.T) { +func TestGRPCAddress(t *testing.T) { tests := []struct { + name string apiHost string - expected bool + address string + expected string }{ - {"http://localhost:8025", true}, - {"http://example.com", true}, - {"https://example.com", false}, - {"https://localhost:8025", false}, - {"example.com", false}, - {"", false}, + {"explicit address wins", "https://panel.example.com", "panel.example.com:31719", "panel.example.com:31719"}, + {"derived from api_host", "https://panel.example.com", "", "panel.example.com:31718"}, + {"derived with path", "https://panel.example.com/some/path", "", "panel.example.com:31718"}, + {"derived with scheme and port", "http://panel.example.com:8080", "", "panel.example.com:31718"}, } for _, tt := range tests { - t.Run(tt.apiHost, func(t *testing.T) { + t.Run(tt.name, func(t *testing.T) { cfg := &Config{APIHost: tt.apiHost} + cfg.GRPC.Address = tt.address + + assert.Equal(t, tt.expected, cfg.GRPCAddress()) + }) + } +} + +func TestIsInsecure(t *testing.T) { + tests := []struct { + name string + apiHost string + grpcInsecure bool + expected bool + }{ + {"http api_host", "http://localhost:8025", false, true}, + {"http api_host without port", "http://example.com", false, true}, + {"https api_host", "https://example.com", false, false}, + {"https api_host with port", "https://localhost:8025", false, false}, + {"api_host without scheme", "example.com", false, false}, + {"empty config", "", false, false}, + {"grpc insecure flag", "", true, true}, + {"grpc insecure flag with https api_host", "https://example.com", true, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{APIHost: tt.apiHost} + cfg.GRPC.Insecure = tt.grpcInsecure + assert.Equal(t, tt.expected, cfg.IsInsecure()) }) } } +func TestValidate_InsecureGRPCDoesNotRequireCertificates(t *testing.T) { + cfg := NewConfig() + cfg.NodeID = 1 + cfg.APIKey = "api-key" + cfg.GRPC.Address = "panel.example.com:31718" + cfg.GRPC.Insecure = true + + assert.NoError(t, cfg.validate()) +} + func givenValidConfig(t *testing.T) *Config { t.Helper() diff --git a/internal/app/config/errors.go b/internal/app/config/errors.go index 01a02f7..620e414 100644 --- a/internal/app/config/errors.go +++ b/internal/app/config/errors.go @@ -7,9 +7,11 @@ import ( ) var ( - ErrEmptyNodeID = errors.New("empty node ID") - ErrEmptyAPIHost = errors.New("empty API Host") - ErrEmptyAPIKey = errors.New("empty API Key") + ErrEmptyNodeID = errors.New("empty node ID") + ErrEmptyAPIKey = errors.New("empty API Key") + ErrNoGRPCAddress = errors.New( + "gRPC address is not configured: set grpc.address (or api_host as a deprecated fallback)", + ) ErrConfigNotFound = errors.New("configuration file not found") ErrUnsupportedConfigFormat = errors.New("unsupported configuration file format") ErrNoCACertificate = errors.New("either ca_certificate or ca_certificate_file must be set") @@ -21,6 +23,12 @@ var ( ErrScopeOnlyForSystemD = errors.New( "process_manager.config.scope is only valid for process_manager.name=systemd", ) + ErrEmptyReplacementKey = errors.New("host key is empty") + ErrDuplicateReplacementKey = errors.New("duplicate host key") + ErrNoReplacementTargets = errors.New("no replacement targets") + ErrEmptyReplacementTarget = errors.New("replacement target is empty") + ErrEmptyReplacementHost = errors.New("replacement host is empty") + ErrReplacementHasQueryOrFragment = errors.New("replacement must not contain a query or a fragment") ) type InvalidFileError struct { diff --git a/internal/app/config/loader.go b/internal/app/config/loader.go index 3f037cf..184a082 100644 --- a/internal/app/config/loader.go +++ b/internal/app/config/loader.go @@ -92,10 +92,6 @@ func updatePaths(cfgPath string, cfg *Config) *Config { cfg.PrivateKeyFile, _ = filepath.Abs(filepath.Join(cfgDirPath, cfg.PrivateKeyFile)) } - if cfg.DHFile != "" && !filepath.IsAbs(cfg.DHFile) { - cfg.DHFile, _ = filepath.Abs(filepath.Join(cfgDirPath, cfg.DHFile)) - } - return cfg } diff --git a/internal/app/config/loader_test.go b/internal/app/config/loader_test.go index e3d68d6..41d4c81 100644 --- a/internal/app/config/loader_test.go +++ b/internal/app/config/loader_test.go @@ -11,7 +11,6 @@ func TestUpdatePaths(t *testing.T) { CACertificateFile: "./certs/ca.crt", CertificateChainFile: "./certs/server.crt", PrivateKeyFile: "./certs/server.key", - DHFile: "./certs/dh2048.pem", } updatedCfg := updatePaths(configPath, cfg) @@ -19,7 +18,6 @@ func TestUpdatePaths(t *testing.T) { assert.Equal(t, caCertificateFilePath, updatedCfg.CACertificateFile) assert.Equal(t, certificateChainFilePath, updatedCfg.CertificateChainFile) assert.Equal(t, privateKeyFilePath, updatedCfg.PrivateKeyFile) - assert.Equal(t, dhFilePathPath, updatedCfg.DHFile) } func TestUpdatePaths_EmptyFilePaths(t *testing.T) { @@ -30,5 +28,4 @@ func TestUpdatePaths_EmptyFilePaths(t *testing.T) { assert.Empty(t, updatedCfg.CACertificateFile) assert.Empty(t, updatedCfg.CertificateChainFile) assert.Empty(t, updatedCfg.PrivateKeyFile) - assert.Empty(t, updatedCfg.DHFile) } diff --git a/internal/app/config/loader_unix_test.go b/internal/app/config/loader_unix_test.go index 15dc91e..0d5e4dc 100644 --- a/internal/app/config/loader_unix_test.go +++ b/internal/app/config/loader_unix_test.go @@ -9,5 +9,4 @@ const ( caCertificateFilePath = "/etc/gameap-daemon/certs/ca.crt" certificateChainFilePath = "/etc/gameap-daemon/certs/server.crt" privateKeyFilePath = "/etc/gameap-daemon/certs/server.key" - dhFilePathPath = "/etc/gameap-daemon/certs/dh2048.pem" ) diff --git a/internal/app/config/loader_windows_test.go b/internal/app/config/loader_windows_test.go index f25a67c..a49f408 100644 --- a/internal/app/config/loader_windows_test.go +++ b/internal/app/config/loader_windows_test.go @@ -9,5 +9,4 @@ const ( caCertificateFilePath = "C:\\gameap\\certs\\ca.crt" certificateChainFilePath = "C:\\gameap\\certs\\server.crt" privateKeyFilePath = "C:\\gameap\\certs\\server.key" - dhFilePathPath = "C:\\gameap\\certs\\dh2048.pem" ) diff --git a/internal/app/config/nodeConfig.go b/internal/app/config/nodeConfig.go deleted file mode 100644 index bb8c059..0000000 --- a/internal/app/config/nodeConfig.go +++ /dev/null @@ -1,154 +0,0 @@ -package config - -import ( - "context" - "encoding/json" - "net/http" - "strconv" - - "github.com/gameap/daemon/internal/app/contracts" - "github.com/gameap/daemon/internal/app/domain" - "github.com/pkg/errors" -) - -type NodeConfigInitializer struct { - client contracts.APIRequestMaker -} - -func NewNodeConfigInitializer(client contracts.APIRequestMaker) *NodeConfigInitializer { - return &NodeConfigInitializer{client: client} -} - -type nodeInitial struct { - WorkPath string `json:"work_path"` - SteamCMDPath string `json:"steamcmd_path"` - PreferInstallMethod string `json:"prefer_install_method"` - ScriptInstall string `json:"script_install"` - ScriptReinstall string `json:"script_reinstall"` - ScriptUpdate string `json:"script_update"` - ScriptStart string `json:"script_start"` - ScriptPause string `json:"script_pause"` - ScriptUnpause string `json:"script_unpause"` - ScriptStop string `json:"script_stop"` - ScriptKill string `json:"script_kill"` - ScriptRestart string `json:"script_restart"` - ScriptStatus string `json:"script_status"` - ScriptGetConsole string `json:"script_get_console"` - ScriptSendCommand string `json:"script_send_command"` - ScriptDelete string `json:"script_delete"` -} - -//nolint:funlen -func (ncu *NodeConfigInitializer) Initialize(ctx context.Context, cfg *Config) error { - resp, err := ncu.client.Request(ctx, domain.APIRequest{ - Method: http.MethodGet, - URL: "gdaemon_api/dedicated_servers/get_init_data/{id}", - PathParams: map[string]string{ - "id": strconv.FormatInt(int64(cfg.NodeID), 10), - }, - }) - - if err != nil { - return errors.WithMessage(err, "[app.nodeConfigInitializer] failed to get node config") - } - - if resp.StatusCode() != http.StatusOK { - return errors.New("[app.nodeConfigInitializer] failed to get node initialization data") - } - - initial := nodeInitial{} - - err = json.Unmarshal(resp.Body(), &initial) - if err != nil { - return errors.WithMessage(err, "[app.nodeConfigInitializer] failed to unmarshal node initialization data") - } - - cfg.WorkPath = initial.WorkPath - cfg.SteamCMDPath = initial.SteamCMDPath - - if cfg.Scripts.Install == "" { - cfg.Scripts.Install = initial.ScriptInstall - } - - if cfg.Scripts.Reinstall == "" { - cfg.Scripts.Reinstall = initial.ScriptReinstall - } - - if cfg.Scripts.Update == "" { - cfg.Scripts.Update = initial.ScriptUpdate - } - - if cfg.Scripts.Start == "" { - cfg.Scripts.Start = initial.ScriptStart - } - - if cfg.Scripts.Pause == "" { - cfg.Scripts.Pause = initial.ScriptPause - } - - if cfg.Scripts.Unpause == "" { - cfg.Scripts.Unpause = initial.ScriptUnpause - } - - if cfg.Scripts.Stop == "" { - cfg.Scripts.Stop = initial.ScriptStop - } - - if cfg.Scripts.Kill == "" { - cfg.Scripts.Kill = initial.ScriptKill - } - - if cfg.Scripts.Restart == "" { - cfg.Scripts.Restart = initial.ScriptRestart - } - - if cfg.Scripts.Status == "" { - cfg.Scripts.Status = initial.ScriptStatus - } - - if cfg.Scripts.GetConsole == "" { - cfg.Scripts.GetConsole = initial.ScriptGetConsole - } - - if cfg.Scripts.SendCommand == "" { - cfg.Scripts.SendCommand = initial.ScriptSendCommand - } - - if cfg.Scripts.Delete == "" { - cfg.Scripts.Delete = initial.ScriptDelete - } - - ncu.initDefault(cfg) - - return nil -} - -func (ncu *NodeConfigInitializer) initDefault(cfg *Config) { - InitDefaultScripts(cfg) -} - -func InitDefaultScripts(cfg *Config) { - if cfg.Scripts.Start == "" { - cfg.Scripts.Start = DefaultGameServerScriptStart - } - - if cfg.Scripts.Stop == "" { - cfg.Scripts.Stop = DefaultGameServerScriptStop - } - - if cfg.Scripts.Restart == "" { - cfg.Scripts.Restart = DefaultGameServerScriptRestart - } - - if cfg.Scripts.Status == "" { - cfg.Scripts.Status = DefaultGameServerScriptStatus - } - - if cfg.Scripts.GetConsole == "" { - cfg.Scripts.GetConsole = DefaultGameServerScriptGetOutput - } - - if cfg.Scripts.SendCommand == "" { - cfg.Scripts.SendCommand = DefaultGameServerScriptSendInput - } -} diff --git a/internal/app/config/repository_replacements.go b/internal/app/config/repository_replacements.go new file mode 100644 index 0000000..95ef9b6 --- /dev/null +++ b/internal/app/config/repository_replacements.go @@ -0,0 +1,172 @@ +package config + +import ( + "net/url" + "slices" + "strings" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// RepositoryReplacements maps a remote repository host (optionally with a port) +// to replacement targets tried in priority order before the original URL. +type RepositoryReplacements map[string]RepositoryReplacementTargets + +type RepositoryReplacementTargets []RepositoryReplacementTarget + +type RepositoryReplacementTarget struct { + Replace string `yaml:"replace"` + Priority int `yaml:"priority"` +} + +// UnmarshalYAML accepts either a single scalar string or a list of +// strings/objects: +// +// files.gameap.ru: cdn.gameap.com +// files.gameap.ru: +// - cdn1.gameap.com +// - replace: cdn2.gameap.com +// priority: 10 +func (t *RepositoryReplacementTargets) UnmarshalYAML(data []byte) error { + var single string + if err := yaml.Unmarshal(data, &single); err == nil { + *t = RepositoryReplacementTargets{{Replace: single}} + return nil + } + + var targets []RepositoryReplacementTarget + if err := yaml.Unmarshal(data, &targets); err != nil { + return errors.Wrapf( + err, + "replacement must be a string or a list of strings/objects with 'replace' and 'priority', got %q", + strings.TrimSpace(string(data)), + ) + } + + *t = targets + + return nil +} + +// UnmarshalYAML accepts either a scalar string (priority 0) or an object +// with 'replace' and 'priority' fields. +func (t *RepositoryReplacementTarget) UnmarshalYAML(data []byte) error { + var single string + if err := yaml.Unmarshal(data, &single); err == nil { + *t = RepositoryReplacementTarget{Replace: single} + return nil + } + + type plain RepositoryReplacementTarget + var target plain + if err := yaml.Unmarshal(data, &target); err != nil { + return errors.Wrapf( + err, + "replacement target must be a string or an object with 'replace' and 'priority', got %q", + strings.TrimSpace(string(data)), + ) + } + + *t = RepositoryReplacementTarget(target) + + return nil +} + +// TargetsForURL returns the replacement targets configured for the URL host. +// A key with a port matches the URL "host:port" exactly, a key without a port +// matches the URL hostname regardless of the port; the more specific key wins. +// Matching is case-insensitive. Nil is returned when nothing matches. +func (r RepositoryReplacements) TargetsForURL(u *url.URL) RepositoryReplacementTargets { + if len(r) == 0 || u == nil { + return nil + } + + host := strings.ToLower(u.Host) + hostname := strings.ToLower(u.Hostname()) + + var hostnameMatch RepositoryReplacementTargets + + for key, targets := range r { + switch strings.ToLower(strings.TrimSpace(key)) { + case host: + return targets + case hostname: + hostnameMatch = targets + } + } + + return hostnameMatch +} + +// Sorted returns a copy ordered by priority, highest first. +// Targets with equal priority keep their configuration order. +func (t RepositoryReplacementTargets) Sorted() RepositoryReplacementTargets { + sorted := slices.Clone(t) + slices.SortStableFunc(sorted, func(a, b RepositoryReplacementTarget) int { + return b.Priority - a.Priority + }) + + return sorted +} + +// URL parses the replacement value as "[scheme://]host[:port][/path-prefix]". +// A value without a scheme keeps the scheme of the URL being replaced. +func (t RepositoryReplacementTarget) URL() (*url.URL, error) { + value := strings.TrimSpace(t.Replace) + if value == "" { + return nil, ErrEmptyReplacementTarget + } + + raw := value + if !strings.Contains(raw, "://") { + raw = "//" + raw + } + + u, err := url.Parse(raw) + if err != nil { + return nil, errors.Wrapf(err, "invalid replacement %q", value) + } + + if u.Host == "" { + return nil, errors.WithMessagef(ErrEmptyReplacementHost, "invalid replacement %q", value) + } + + if u.RawQuery != "" || u.Fragment != "" { + return nil, errors.WithMessagef(ErrReplacementHasQueryOrFragment, "invalid replacement %q", value) + } + + return u, nil +} + +func (r RepositoryReplacements) validate() error { + normalizedKeys := make(map[string]string, len(r)) + + for key, targets := range r { + normalizedKey := strings.ToLower(strings.TrimSpace(key)) + if normalizedKey == "" { + return errors.WithMessage(ErrEmptyReplacementKey, "remote_repository_replacements") + } + + if duplicateOf, ok := normalizedKeys[normalizedKey]; ok { + return errors.WithMessagef( + ErrDuplicateReplacementKey, + "remote_repository_replacements: %q and %q", + duplicateOf, key, + ) + } + normalizedKeys[normalizedKey] = key + + if len(targets) == 0 { + return errors.WithMessagef(ErrNoReplacementTargets, "remote_repository_replacements[%s]", key) + } + + for _, target := range targets { + if _, err := target.URL(); err != nil { + return errors.WithMessagef(err, "remote_repository_replacements[%s]", key) + } + } + } + + return nil +} diff --git a/internal/app/config/repository_replacements_test.go b/internal/app/config/repository_replacements_test.go new file mode 100644 index 0000000..92d8347 --- /dev/null +++ b/internal/app/config/repository_replacements_test.go @@ -0,0 +1,309 @@ +package config + +import ( + "net/url" + "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepositoryReplacements_UnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yaml string + expected RepositoryReplacements + }{ + { + name: "single string", + yaml: ` +remote_repository_replacements: + files.gameap.ru: cdn.gameap.com +`, + expected: RepositoryReplacements{ + "files.gameap.ru": {{Replace: "cdn.gameap.com"}}, + }, + }, + { + name: "list of strings", + yaml: ` +remote_repository_replacements: + files.gameap.ru: + - cdn.gameap.com + - cdn.gameap.ru +`, + expected: RepositoryReplacements{ + "files.gameap.ru": { + {Replace: "cdn.gameap.com"}, + {Replace: "cdn.gameap.ru"}, + }, + }, + }, + { + name: "list of objects with priority", + yaml: ` +remote_repository_replacements: + files.gameap.ru: + - replace: cdn.gameap.com + priority: 10 + - replace: cdn.gameap.ru + priority: 9 +`, + expected: RepositoryReplacements{ + "files.gameap.ru": { + {Replace: "cdn.gameap.com", Priority: 10}, + {Replace: "cdn.gameap.ru", Priority: 9}, + }, + }, + }, + { + name: "mixed list of strings and objects", + yaml: ` +remote_repository_replacements: + files.gameap.ru: + - cdn.gameap.com + - replace: cdn.gameap.ru + priority: 5 +`, + expected: RepositoryReplacements{ + "files.gameap.ru": { + {Replace: "cdn.gameap.com"}, + {Replace: "cdn.gameap.ru", Priority: 5}, + }, + }, + }, + { + name: "multiple hosts", + yaml: ` +remote_repository_replacements: + files.gameap.ru: cdn.gameap.com + files.example.com: + - cdn.example.com +`, + expected: RepositoryReplacements{ + "files.gameap.ru": {{Replace: "cdn.gameap.com"}}, + "files.example.com": {{Replace: "cdn.example.com"}}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := &Config{} + + err := yaml.Unmarshal([]byte(test.yaml), cfg) + + require.NoError(t, err) + assert.Equal(t, test.expected, cfg.RemoteRepositoryReplacements) + }) + } +} + +func TestRepositoryReplacements_UnmarshalYAML_InvalidValue_ExpectError(t *testing.T) { + cfg := &Config{} + + err := yaml.Unmarshal([]byte(` +remote_repository_replacements: + files.gameap.ru: + replace: cdn.gameap.com +`), cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a string or a list") +} + +func TestRepositoryReplacements_Validate(t *testing.T) { + tests := []struct { + name string + replacements RepositoryReplacements + expectedError error + }{ + { + "empty host key", + RepositoryReplacements{" ": {{Replace: "cdn.gameap.com"}}}, + ErrEmptyReplacementKey, + }, + { + "duplicate host key", + RepositoryReplacements{ + "files.gameap.ru": {{Replace: "cdn.gameap.com"}}, + "Files.GameAP.RU": {{Replace: "cdn.gameap.ru"}}, + }, + ErrDuplicateReplacementKey, + }, + { + "no targets", + RepositoryReplacements{"files.gameap.ru": {}}, + ErrNoReplacementTargets, + }, + { + "empty replacement target", + RepositoryReplacements{"files.gameap.ru": {{Replace: " "}}}, + ErrEmptyReplacementTarget, + }, + { + "replacement without host", + RepositoryReplacements{"files.gameap.ru": {{Replace: "https://"}}}, + ErrEmptyReplacementHost, + }, + { + "replacement with query", + RepositoryReplacements{"files.gameap.ru": {{Replace: "cdn.gameap.com/mirror?token=x"}}}, + ErrReplacementHasQueryOrFragment, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := givenValidConfig(t) + cfg.RemoteRepositoryReplacements = test.replacements + + err := cfg.Init() + + assert.ErrorIs(t, err, test.expectedError) + }) + } +} + +func TestRepositoryReplacements_Validate_ValidConfig(t *testing.T) { + cfg := givenValidConfig(t) + cfg.RemoteRepositoryReplacements = RepositoryReplacements{ + "files.gameap.ru": { + {Replace: "cdn.gameap.com", Priority: 10}, + {Replace: "https://mirror.gameap.ru/files", Priority: 9}, + {Replace: "cdn.gameap.ru:8080"}, + }, + } + + err := cfg.Init() + + assert.NoError(t, err) +} + +func TestRepositoryReplacements_TargetsForURL(t *testing.T) { + replacements := RepositoryReplacements{ + "files.gameap.ru": {{Replace: "cdn.gameap.com"}}, + "files.gameap.ru:8080": {{Replace: "cdn-alt.gameap.com"}}, + "Files.Example.COM": {{Replace: "cdn.example.com"}}, + } + + tests := []struct { + name string + url string + expected RepositoryReplacementTargets + }{ + { + "key without port matches URL without port", + "http://files.gameap.ru/game.tar.xz", + RepositoryReplacementTargets{{Replace: "cdn.gameap.com"}}, + }, + { + "key with port wins for URL with matching port", + "http://files.gameap.ru:8080/game.tar.xz", + RepositoryReplacementTargets{{Replace: "cdn-alt.gameap.com"}}, + }, + { + "key without port matches URL with any other port", + "http://files.gameap.ru:9090/game.tar.xz", + RepositoryReplacementTargets{{Replace: "cdn.gameap.com"}}, + }, + { + "matching is case-insensitive", + "http://FILES.example.com/game.tar.xz", + RepositoryReplacementTargets{{Replace: "cdn.example.com"}}, + }, + { + "no match", + "http://other.gameap.ru/game.tar.xz", + nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + u, err := url.Parse(test.url) + require.NoError(t, err) + + assert.Equal(t, test.expected, replacements.TargetsForURL(u)) + }) + } +} + +func TestRepositoryReplacementTargets_Sorted(t *testing.T) { + targets := RepositoryReplacementTargets{ + {Replace: "third", Priority: 5}, + {Replace: "first", Priority: 10}, + {Replace: "fourth", Priority: 5}, + {Replace: "second", Priority: 7}, + } + + sorted := targets.Sorted() + + assert.Equal(t, RepositoryReplacementTargets{ + {Replace: "first", Priority: 10}, + {Replace: "second", Priority: 7}, + {Replace: "third", Priority: 5}, + {Replace: "fourth", Priority: 5}, + }, sorted) + assert.Equal(t, RepositoryReplacementTargets{ + {Replace: "third", Priority: 5}, + {Replace: "first", Priority: 10}, + {Replace: "fourth", Priority: 5}, + {Replace: "second", Priority: 7}, + }, targets) +} + +func TestRepositoryReplacementTarget_URL(t *testing.T) { + tests := []struct { + name string + replace string + expectedScheme string + expectedHost string + expectedPath string + expectedError error + }{ + {name: "host only", replace: "cdn.gameap.com", expectedHost: "cdn.gameap.com"}, + {name: "host with port", replace: "cdn.gameap.com:8080", expectedHost: "cdn.gameap.com:8080"}, + { + name: "host with scheme", + replace: "https://cdn.gameap.com", + expectedScheme: "https", + expectedHost: "cdn.gameap.com", + }, + { + name: "host with path prefix", + replace: "cdn.gameap.com/mirror", + expectedHost: "cdn.gameap.com", + expectedPath: "/mirror", + }, + { + name: "scheme, host, port and path prefix", + replace: "https://cdn.gameap.com:8080/mirror/files", + expectedScheme: "https", + expectedHost: "cdn.gameap.com:8080", + expectedPath: "/mirror/files", + }, + {name: "surrounding spaces are trimmed", replace: " cdn.gameap.com ", expectedHost: "cdn.gameap.com"}, + {name: "empty", replace: "", expectedError: ErrEmptyReplacementTarget}, + {name: "blank", replace: " ", expectedError: ErrEmptyReplacementTarget}, + {name: "scheme without host", replace: "https://", expectedError: ErrEmptyReplacementHost}, + {name: "with query", replace: "cdn.gameap.com?a=b", expectedError: ErrReplacementHasQueryOrFragment}, + {name: "with fragment", replace: "cdn.gameap.com/mirror#frag", expectedError: ErrReplacementHasQueryOrFragment}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + u, err := RepositoryReplacementTarget{Replace: test.replace}.URL() + + if test.expectedError != nil { + assert.ErrorIs(t, err, test.expectedError) + return + } + + require.NoError(t, err) + assert.Equal(t, test.expectedScheme, u.Scheme) + assert.Equal(t, test.expectedHost, u.Host) + assert.Equal(t, test.expectedPath, u.Path) + }) + } +} diff --git a/internal/app/config/scripts.go b/internal/app/config/scripts.go new file mode 100644 index 0000000..9911c57 --- /dev/null +++ b/internal/app/config/scripts.go @@ -0,0 +1,27 @@ +package config + +func InitDefaultScripts(cfg *Config) { + if cfg.Scripts.Start == "" { + cfg.Scripts.Start = DefaultGameServerScriptStart + } + + if cfg.Scripts.Stop == "" { + cfg.Scripts.Stop = DefaultGameServerScriptStop + } + + if cfg.Scripts.Restart == "" { + cfg.Scripts.Restart = DefaultGameServerScriptRestart + } + + if cfg.Scripts.Status == "" { + cfg.Scripts.Status = DefaultGameServerScriptStatus + } + + if cfg.Scripts.GetConsole == "" { + cfg.Scripts.GetConsole = DefaultGameServerScriptGetOutput + } + + if cfg.Scripts.SendCommand == "" { + cfg.Scripts.SendCommand = DefaultGameServerScriptSendInput + } +} diff --git a/internal/app/config/writer.go b/internal/app/config/writer.go index 7432a14..ea609e8 100644 --- a/internal/app/config/writer.go +++ b/internal/app/config/writer.go @@ -11,8 +11,6 @@ import ( type EnrollConfig struct { NodeID uint `yaml:"ds_id"` APIKey string `yaml:"api_key"` - ListenIP string `yaml:"listen_ip"` - ListenPort int `yaml:"listen_port"` CACertificateFile string `yaml:"ca_certificate_file"` CertificateChainFile string `yaml:"certificate_chain_file"` PrivateKeyFile string `yaml:"private_key_file"` @@ -25,7 +23,6 @@ type EnrollConfig struct { } type EnrollGRPC struct { - Enabled bool `yaml:"enabled"` Address string `yaml:"address"` } diff --git a/internal/app/config/writer_test.go b/internal/app/config/writer_test.go index fcde5f0..52d74c1 100644 --- a/internal/app/config/writer_test.go +++ b/internal/app/config/writer_test.go @@ -16,15 +16,12 @@ func TestWriteEnrollConfig(t *testing.T) { cfg := &EnrollConfig{ NodeID: 42, APIKey: "test-api-key", - ListenIP: "0.0.0.0", - ListenPort: 31717, CACertificateFile: "/etc/gameap-daemon/certs/ca.crt", CertificateChainFile: "/etc/gameap-daemon/certs/server.crt", PrivateKeyFile: "/etc/gameap-daemon/certs/server.key", WorkPath: "/srv/gameap", LogLevel: "info", GRPC: EnrollGRPC{ - Enabled: true, Address: "panel.example.com:31718", }, } @@ -38,8 +35,6 @@ func TestWriteEnrollConfig(t *testing.T) { content := string(data) assert.Contains(t, content, "ds_id: 42") assert.Contains(t, content, "api_key: test-api-key") - assert.Contains(t, content, "listen_ip: 0.0.0.0") - assert.Contains(t, content, "listen_port: 31717") assert.Contains(t, content, "ca_certificate_file: /etc/gameap-daemon/certs/ca.crt") assert.Contains(t, content, "certificate_chain_file: /etc/gameap-daemon/certs/server.crt") assert.Contains(t, content, "private_key_file: /etc/gameap-daemon/certs/server.key") @@ -47,7 +42,6 @@ func TestWriteEnrollConfig(t *testing.T) { assert.Contains(t, content, "if_list:") assert.Contains(t, content, "drives_list:") assert.Contains(t, content, "log_level: info") - assert.Contains(t, content, "enabled: true") assert.Contains(t, content, "address: panel.example.com:31718") info, err := os.Stat(path) @@ -60,10 +54,8 @@ func TestWriteEnrollConfig_CreatesParentDir(t *testing.T) { path := filepath.Join(dir, "subdir", "nested", "gameap-daemon.yaml") cfg := &EnrollConfig{ - NodeID: 1, - ListenPort: 31717, + NodeID: 1, GRPC: EnrollGRPC{ - Enabled: true, Address: "localhost:31718", }, } diff --git a/internal/app/contracts/contracts.go b/internal/app/contracts/contracts.go index b80a188..0f8ede8 100644 --- a/internal/app/contracts/contracts.go +++ b/internal/app/contracts/contracts.go @@ -36,21 +36,16 @@ type GameServerCommand interface { Execute(ctx context.Context, server *domain.Server) error } -type APIRequestMaker interface { - Request(ctx context.Context, request domain.APIRequest) (APIResponse, error) -} - -type APIResponse interface { - Body() []byte - Status() string - StatusCode() int - - Error() interface{} -} - type Executor interface { Exec(ctx context.Context, command string, options ExecutorOptions) ([]byte, int, error) ExecWithWriter(ctx context.Context, command string, out io.Writer, options ExecutorOptions) (int, error) + + // ExecArgs and ExecWithWriterArgs execute a pre-tokenized argument vector + // directly, without any shell-style splitting. Callers that build a command + // from user-controlled values must use these so each value stays a single + // argument. + ExecArgs(ctx context.Context, args []string, options ExecutorOptions) ([]byte, int, error) + ExecWithWriterArgs(ctx context.Context, args []string, out io.Writer, options ExecutorOptions) (int, error) } type ProcessManager interface { diff --git a/internal/app/di/container.go b/internal/app/di/container.go index 7ee9a0e..720db81 100644 --- a/internal/app/di/container.go +++ b/internal/app/di/container.go @@ -9,7 +9,6 @@ import ( "sync" "github.com/gameap/daemon/internal/app/config" - "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/internal/app/di/internal" "github.com/gameap/daemon/internal/app/domain" grpcclient "github.com/gameap/daemon/internal/app/grpc" @@ -61,27 +60,6 @@ func (c *Container) ProcessRunner(ctx context.Context) (*services.Runner, error) return s, err } -func SetApiCaller(s contracts.APIRequestMaker) Injector { - return func(c *Container) error { - c.c.Services().(*internal.ServicesContainer).SetAPICaller(s) - - return nil - } -} - -func (c *Container) GdTaskRepository(ctx context.Context) (domain.GDTaskRepository, error) { - c.mu.Lock() - defer c.mu.Unlock() - - s := c.c.Repositories().(*internal.RepositoryContainer).GdTaskRepository(ctx) - err := c.c.Error() - if err != nil { - return nil, err - } - - return s, err -} - func (c *Container) ServerRepository(ctx context.Context) (domain.ServerRepository, error) { c.mu.Lock() defer c.mu.Unlock() @@ -95,19 +73,6 @@ func (c *Container) ServerRepository(ctx context.Context) (domain.ServerReposito return s, err } -func (c *Container) ServerTaskRepository(ctx context.Context) (domain.ServerTaskRepository, error) { - c.mu.Lock() - defer c.mu.Unlock() - - s := c.c.Repositories().(*internal.RepositoryContainer).ServerTaskRepository(ctx) - err := c.c.Error() - if err != nil { - return nil, err - } - - return s, err -} - func (c *Container) GatewayClient(ctx context.Context) (*grpcclient.GatewayClient, error) { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/app/di/internal/_config.go b/internal/app/di/internal/_config.go index 7b2caf4..065f9e0 100644 --- a/internal/app/di/internal/_config.go +++ b/internal/app/di/internal/_config.go @@ -7,12 +7,16 @@ import ( gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" gdaemonscheduler "github.com/gameap/daemon/internal/app/gdaemon_scheduler" "github.com/gameap/daemon/internal/app/services" - "github.com/go-resty/resty/v2" "github.com/sirupsen/logrus" ) // Container is a root dependency injection container. It is required to describe // your services. +// +// NOTE: the generated containers in this package have been hand-maintained +// since the gRPC wiring was added (GameStore, GatewayClient, ConnectionManager, +// servers scheduler). Do not regenerate them with digen — it would drop that +// wiring. This file is kept in sync manually as documentation. type Container struct { cfg *config.Config `di:"required"` logger *logrus.Logger `di:"required"` @@ -28,15 +32,11 @@ type Container struct { } type ServicesContainer struct { - resty *resty.Client - apiCaller contracts.APIRequestMaker `di:"set"` - executor contracts.Executor + executor contracts.Executor gdTaskManager *gdaemonscheduler.TaskManager } type RepositoryContainer struct { - gdTaskRepository domain.GDTaskRepository `di:"public, set"` - serverRepository domain.ServerRepository `di:"public, set"` - serverTaskRepository domain.ServerTaskRepository `di:"public, set"` + serverRepository domain.ServerRepository `di:"public"` } diff --git a/internal/app/di/internal/container.go b/internal/app/di/internal/container.go index a2f2c54..0c99061 100644 --- a/internal/app/di/internal/container.go +++ b/internal/app/di/internal/container.go @@ -9,12 +9,12 @@ import ( grpcclient "github.com/gameap/daemon/internal/app/grpc" "github.com/gameap/daemon/internal/app/metrics" "github.com/gameap/daemon/internal/app/services" - "github.com/go-resty/resty/v2" "github.com/sirupsen/logrus" "github.com/gameap/daemon/internal/app/di/internal/definitions" "github.com/gameap/daemon/internal/app/domain" gdaemonscheduler "github.com/gameap/daemon/internal/app/gdaemon_scheduler" + serversscheduler "github.com/gameap/daemon/internal/app/servers_scheduler" ) type Container struct { @@ -32,6 +32,7 @@ type Container struct { fileTransferClient *grpcclient.FileTransferClient serverStatusReporter *grpcclient.ServerStatusReporter metricsService *metrics.Service + serversScheduler *serversscheduler.Scheduler services *ServicesContainer repositories *RepositoryContainer @@ -61,8 +62,6 @@ func (c *Container) SetError(err error) { type ServicesContainer struct { *Container - resty *resty.Client - apiCaller contracts.APIRequestMaker executor contracts.Executor processManager contracts.ProcessManager gdTaskManager *gdaemonscheduler.TaskManager @@ -71,9 +70,7 @@ type ServicesContainer struct { type RepositoryContainer struct { *Container - gdTaskRepository domain.GDTaskRepository - serverRepository domain.ServerRepository - serverTaskRepository domain.ServerTaskRepository + serverRepository domain.ServerRepository } func (c *Container) Cfg(_ context.Context) *config.Config { @@ -148,22 +145,16 @@ func (c *Container) MetricsService(ctx context.Context) *metrics.Service { return c.metricsService } -func (c *Container) Services() definitions.ServicesContainer { - return c.services +func (c *Container) ServersScheduler(_ context.Context) *serversscheduler.Scheduler { + return c.serversScheduler } -func (c *ServicesContainer) Resty(ctx context.Context) *resty.Client { - if c.resty == nil && c.err == nil { - c.resty = definitions.CreateServicesResty(ctx, c) - } - return c.resty +func (c *Container) SetServersScheduler(s *serversscheduler.Scheduler) { + c.serversScheduler = s } -func (c *ServicesContainer) APICaller(ctx context.Context) contracts.APIRequestMaker { - if c.apiCaller == nil && c.err == nil { - c.apiCaller = definitions.CreateServicesAPICaller(ctx, c) - } - return c.apiCaller +func (c *Container) Services() definitions.ServicesContainer { + return c.services } func (c *ServicesContainer) Executor(ctx context.Context) contracts.Executor { @@ -199,13 +190,6 @@ func (c *Container) Repositories() definitions.RepositoryContainer { return c.repositories } -func (c *RepositoryContainer) GdTaskRepository(ctx context.Context) domain.GDTaskRepository { - if c.gdTaskRepository == nil && c.err == nil { - c.gdTaskRepository = definitions.CreateRepositoriesGdTaskRepository(ctx, c) - } - return c.gdTaskRepository -} - func (c *RepositoryContainer) ServerRepository(ctx context.Context) domain.ServerRepository { if c.serverRepository == nil && c.err == nil { c.serverRepository = definitions.CreateRepositoriesServerRepository(ctx, c) @@ -213,13 +197,6 @@ func (c *RepositoryContainer) ServerRepository(ctx context.Context) domain.Serve return c.serverRepository } -func (c *RepositoryContainer) ServerTaskRepository(ctx context.Context) domain.ServerTaskRepository { - if c.serverTaskRepository == nil && c.err == nil { - c.serverTaskRepository = definitions.CreateRepositoriesServerTaskRepository(ctx, c) - } - return c.serverTaskRepository -} - func (c *Container) SetCfg(s *config.Config) { c.cfg = s } @@ -228,8 +205,4 @@ func (c *Container) SetLogger(s *logrus.Logger) { c.logger = s } -func (c *ServicesContainer) SetAPICaller(s contracts.APIRequestMaker) { - c.apiCaller = s -} - func (c *Container) Close() {} diff --git a/internal/app/di/internal/definitions/container.go b/internal/app/di/internal/definitions/container.go index af346e4..3c1914d 100644 --- a/internal/app/di/internal/definitions/container.go +++ b/internal/app/di/internal/definitions/container.go @@ -11,12 +11,9 @@ import ( func CreateProcessRunner(ctx context.Context, c Container) *services.Runner { processRunner, err := services.NewProcessRunner( c.Cfg(ctx), - c.Services().ExtendableExecutor(ctx), c.ServerCommandFactory(ctx), - c.Services().APICaller(ctx), c.Services().GdTaskManager(ctx), c.Repositories().ServerRepository(ctx), - c.Repositories().ServerTaskRepository(ctx), ) if err != nil { c.SetError(err) diff --git a/internal/app/di/internal/definitions/contracts.go b/internal/app/di/internal/definitions/contracts.go index 6ebebb5..5b93f3f 100644 --- a/internal/app/di/internal/definitions/contracts.go +++ b/internal/app/di/internal/definitions/contracts.go @@ -11,11 +11,11 @@ import ( gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" "github.com/gameap/daemon/internal/app/metrics" "github.com/gameap/daemon/internal/app/services" - "github.com/go-resty/resty/v2" "github.com/sirupsen/logrus" "github.com/gameap/daemon/internal/app/domain" gdaemonscheduler "github.com/gameap/daemon/internal/app/gdaemon_scheduler" + serversscheduler "github.com/gameap/daemon/internal/app/servers_scheduler" ) type Container interface { @@ -29,13 +29,13 @@ type Container interface { ServerCommandFactory(ctx context.Context) *gameservercommands.ServerCommandFactory MetricsService(ctx context.Context) *metrics.Service + SetServersScheduler(s *serversscheduler.Scheduler) + Services() ServicesContainer Repositories() RepositoryContainer } type ServicesContainer interface { - Resty(ctx context.Context) *resty.Client - APICaller(ctx context.Context) contracts.APIRequestMaker Executor(ctx context.Context) contracts.Executor ExtendableExecutor(ctx context.Context) contracts.Executor GdTaskManager(ctx context.Context) *gdaemonscheduler.TaskManager @@ -43,7 +43,5 @@ type ServicesContainer interface { } type RepositoryContainer interface { - GdTaskRepository(ctx context.Context) domain.GDTaskRepository ServerRepository(ctx context.Context) domain.ServerRepository - ServerTaskRepository(ctx context.Context) domain.ServerTaskRepository } diff --git a/internal/app/di/internal/definitions/grpc.go b/internal/app/di/internal/definitions/grpc.go index c3d546a..a65e238 100644 --- a/internal/app/di/internal/definitions/grpc.go +++ b/internal/app/di/internal/definitions/grpc.go @@ -5,6 +5,7 @@ import ( grpcclient "github.com/gameap/daemon/internal/app/grpc" "github.com/gameap/daemon/internal/app/repositories" + serversscheduler "github.com/gameap/daemon/internal/app/servers_scheduler" ) func CreateGameStore() *grpcclient.GameStore { @@ -70,6 +71,10 @@ func CreateConnectionManager( ) client.SetTransferHandler(transferHandler) + // 0 selects the handler's own default concurrency. + archiveHandler := grpcclient.NewGRPCArchiveHandler(cfg.WorkPath, client, 0) + client.SetArchiveHandler(archiveHandler) + serverRepo := c.Repositories().ServerRepository(ctx).(*repositories.ServerRepository) attachHandler := grpcclient.NewGRPCAttachHandler( serverRepo, @@ -94,6 +99,16 @@ func CreateConnectionManager( c.Services().GdTaskManager(ctx).SetTaskStatusSender(client) + scheduler := serversscheduler.NewScheduler( + cfg, + c.ServerCommandFactory(ctx), + serverRepo, + client, + ) + client.SetServerTaskFlow(scheduler) + c.SetServersScheduler(scheduler) + c.ProcessRunner(ctx).SetServersScheduler(scheduler) + cm := grpcclient.NewConnectionManager(cfg, client) cm.OnConnect(fileTransferClient.SetConnection) diff --git a/internal/app/di/internal/definitions/repositories.go b/internal/app/di/internal/definitions/repositories.go index 45a5d46..652fd15 100644 --- a/internal/app/di/internal/definitions/repositories.go +++ b/internal/app/di/internal/definitions/repositories.go @@ -7,17 +7,6 @@ import ( "github.com/gameap/daemon/internal/app/repositories" ) -func CreateRepositoriesGdTaskRepository(ctx context.Context, c Container) domain.GDTaskRepository { - return repositories.NewGDTaskRepository( - c.Services().APICaller(ctx), - c.Repositories().ServerRepository(ctx), - ) -} - -func CreateRepositoriesServerRepository(ctx context.Context, c Container) domain.ServerRepository { - return repositories.NewServerRepository(ctx, c.Services().APICaller(ctx), c.Logger(ctx)) -} - -func CreateRepositoriesServerTaskRepository(ctx context.Context, c Container) domain.ServerTaskRepository { - return repositories.NewServerTaskRepository(c.Services().APICaller(ctx), c.Repositories().ServerRepository(ctx)) +func CreateRepositoriesServerRepository(_ context.Context, _ Container) domain.ServerRepository { + return repositories.NewServerRepository() } diff --git a/internal/app/di/internal/definitions/services.go b/internal/app/di/internal/definitions/services.go index 5cc16dc..d41dfdb 100644 --- a/internal/app/di/internal/definitions/services.go +++ b/internal/app/di/internal/definitions/services.go @@ -2,56 +2,14 @@ package definitions import ( "context" - "net/http" - "time" "github.com/gameap/daemon/internal/app/components" "github.com/gameap/daemon/internal/app/components/customhandlers" "github.com/gameap/daemon/internal/app/contracts" gdaemonscheduler "github.com/gameap/daemon/internal/app/gdaemon_scheduler" - "github.com/gameap/daemon/internal/app/services" "github.com/gameap/daemon/internal/processmanager" - "github.com/go-resty/resty/v2" ) -func CreateServicesResty(ctx context.Context, c Container) *resty.Client { - restyClient := resty.New() - restyClient.SetBaseURL(c.Cfg(ctx).APIHost) - restyClient.SetHeader("User-Agent", "GameAP Daemon/3.0") - restyClient.RetryCount = 30 - restyClient.RetryMaxWaitTime = 10 * time.Minute - restyClient.SetTimeout(10 * time.Second) - restyClient.SetLogger(c.Logger(ctx)) - - restyClient.AddRetryCondition( - func(r *resty.Response, err error) bool { - return r.StatusCode() == http.StatusTooManyRequests || - r.StatusCode() == http.StatusBadGateway - }, - ) - - return restyClient -} - -func CreateServicesAPICaller(ctx context.Context, c Container) contracts.APIRequestMaker { - if c.Cfg(ctx).GRPC.Enabled { - return &services.NoopAPICaller{} - } - - client, err := services.NewAPICaller( - ctx, - c.Cfg(ctx), - c.Services().Resty(ctx), - ) - - if err != nil { - c.SetError(err) - return nil - } - - return client -} - func CreateServicesExecutor(_ context.Context, _ Container) contracts.Executor { return components.NewCleanExecutor() } @@ -96,7 +54,6 @@ func CreateServicesProcessManager(ctx context.Context, c Container) contracts.Pr func CreateServicesGdTaskManager(ctx context.Context, c Container) *gdaemonscheduler.TaskManager { return gdaemonscheduler.NewTaskManager( - c.Repositories().GdTaskRepository(ctx), c.CacheManager(ctx), c.ServerCommandFactory(ctx), c.Services().ExtendableExecutor(ctx), diff --git a/internal/app/domain/api_request.go b/internal/app/domain/api_request.go deleted file mode 100644 index fab9702..0000000 --- a/internal/app/domain/api_request.go +++ /dev/null @@ -1,12 +0,0 @@ -package domain - -import "net/http" - -type APIRequest struct { - Method string - URL string - Header http.Header - QueryParams map[string]string - PathParams map[string]string - Body []byte -} diff --git a/internal/app/domain/commands.go b/internal/app/domain/commands.go index 1ad711b..3ddf1ce 100644 --- a/internal/app/domain/commands.go +++ b/internal/app/domain/commands.go @@ -3,8 +3,61 @@ package domain import ( "strconv" "strings" + + "github.com/gameap/daemon/pkg/shellquote" + "github.com/pkg/errors" ) +// BuildCommandArgs turns a wrapper template and a server command template into a +// concrete argument vector. Both templates are tokenized first and placeholders +// are substituted into the individual tokens afterwards, so every substituted +// value (server name, vars, rcon password, ...) always stays within a single +// argument regardless of the spaces, quotes or shell metacharacters it contains. +// +// The {command} placeholder is the only one that may expand into more than one +// argument: as a standalone token in the wrapper it is replaced by the tokens of +// the server command. +func BuildCommandArgs( + cfg workDirReader, + server *Server, + wrapperTemplate string, + serverCommand string, +) ([]string, error) { + if wrapperTemplate == "" { + return nil, nil + } + + wrapTokens, err := shellquote.Split(wrapperTemplate) + if err != nil { + return nil, errors.WithMessage(err, "failed to split command wrapper template") + } + + var cmdTokens []string + if serverCommand != "" { + cmdTokens, err = shellquote.Split(serverCommand) + if err != nil { + return nil, errors.WithMessage(err, "failed to split server command") + } + } + + replacer := newServerReplacer(cfg, server) + + args := make([]string, 0, len(wrapTokens)+len(cmdTokens)) + for _, token := range wrapTokens { + if token == "{command}" { + for _, cmdToken := range cmdTokens { + args = append(args, replacer.Replace(cmdToken)) + } + + continue + } + + args = append(args, replacer.Replace(token)) + } + + return args, nil +} + func MakeFullCommand( cfg workDirReader, server *Server, @@ -17,33 +70,46 @@ func MakeFullCommand( } func ReplaceShortCodes(commandTemplate string, cfg workDirReader, server *Server) string { - command := commandTemplate - - command = strings.ReplaceAll(command, "{dir}", server.WorkDir(cfg)) - command = strings.ReplaceAll(command, "{uuid}", server.UUID()) - command = strings.ReplaceAll(command, "{uuid_short}", server.UUIDShort()) - command = strings.ReplaceAll(command, "{id}", strconv.Itoa(server.ID())) - - command = strings.ReplaceAll(command, "{host}", server.IP()) - command = strings.ReplaceAll(command, "{ip}", server.IP()) - command = strings.ReplaceAll(command, "{port}", strconv.Itoa(server.ConnectPort())) - command = strings.ReplaceAll(command, "{SERVER_PORT}", strconv.Itoa(server.ConnectPort())) - command = strings.ReplaceAll(command, "{PORT}", strconv.Itoa(server.ConnectPort())) - command = strings.ReplaceAll(command, "{query_port}", strconv.Itoa(server.QueryPort())) - command = strings.ReplaceAll(command, "{rcon_port}", strconv.Itoa(server.RCONPort())) - command = strings.ReplaceAll(command, "{rcon_password}", server.RCONPassword()) - - command = strings.ReplaceAll(command, "{game}", server.Game().StartCode) - command = strings.ReplaceAll(command, "{user}", server.User()) - - command = strings.ReplaceAll(command, "{node_work_path}", cfg.WorkDir()) - command = strings.ReplaceAll(command, "{node_tools_path}", cfg.WorkDir()+"/tools") - - for k, v := range server.Vars() { - command = strings.ReplaceAll(command, "{"+k+"}", v) - command = strings.ReplaceAll(command, "{"+strings.ToLower(k)+"}", v) - command = strings.ReplaceAll(command, "{"+strings.ToUpper(k)+"}", v) + return newServerReplacer(cfg, server).Replace(commandTemplate) +} + +// newServerReplacer builds a single-pass replacer for all supported +// placeholders. A single pass means a substituted value is never re-scanned, so +// one variable value cannot expand another variable's placeholder, and the +// result no longer depends on Go map iteration order. Built-in placeholders are +// registered before server variables, so a variable cannot shadow a built-in. +func newServerReplacer(cfg workDirReader, server *Server) *strings.Replacer { + vars := server.Vars() + + const builtinPairs = 32 + + pairs := make([]string, 0, builtinPairs+6*len(vars)) + pairs = append(pairs, + "{dir}", server.WorkDir(cfg), + "{uuid}", server.UUID(), + "{uuid_short}", server.UUIDShort(), + "{id}", strconv.Itoa(server.ID()), + "{host}", server.IP(), + "{ip}", server.IP(), + "{port}", strconv.Itoa(server.ConnectPort()), + "{SERVER_PORT}", strconv.Itoa(server.ConnectPort()), + "{PORT}", strconv.Itoa(server.ConnectPort()), + "{query_port}", strconv.Itoa(server.QueryPort()), + "{rcon_port}", strconv.Itoa(server.RCONPort()), + "{rcon_password}", server.RCONPassword(), + "{game}", server.Game().StartCode, + "{user}", server.User(), + "{node_work_path}", cfg.WorkDir(), + "{node_tools_path}", cfg.WorkDir()+"/tools", + ) + + for k, v := range vars { + pairs = append(pairs, + "{"+k+"}", v, + "{"+strings.ToLower(k)+"}", v, + "{"+strings.ToUpper(k)+"}", v, + ) } - return command + return strings.NewReplacer(pairs...) } diff --git a/internal/app/domain/commands_args_test.go b/internal/app/domain/commands_args_test.go new file mode 100644 index 0000000..9c3ed28 --- /dev/null +++ b/internal/app/domain/commands_args_test.go @@ -0,0 +1,109 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildCommandArgs_KeepsSubstitutedValueAsSingleArgument(t *testing.T) { + cfg := fakeWorkDirReader{workDir: "/work-path"} + + tests := []struct { + name string + hostname string + }{ + {"single_quote_and_space", "Andrey's Server"}, + {"dollars_quotes_and_space", `$$$ Andrey's Server"$$"`}, + {"command_substitution", "$(id)"}, + {"backticks", "`reboot`"}, + {"shell_metacharacters", "a; rm -rf / | cat"}, + {"leading_and_trailing_spaces", " padded "}, + {"percent_and_variable", "100% $HOME"}, + {"backslashes", `C:\path\to\thing`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newTestServerForVars(nil, map[string]string{"hostname": tt.hostname}, nil) + + args, err := BuildCommandArgs(cfg, server, "{command}", "./run --name {hostname}") + + require.NoError(t, err) + require.Len(t, args, 3) + assert.Equal(t, "./run", args[0]) + assert.Equal(t, "--name", args[1]) + assert.Equal(t, tt.hostname, args[2]) + }) + } +} + +func TestBuildCommandArgs_QuotedPlaceholderStaysSingleArgument(t *testing.T) { + cfg := fakeWorkDirReader{workDir: "/work-path"} + server := newTestServerForVars(nil, map[string]string{"hostname": "My Server"}, nil) + + args, err := BuildCommandArgs(cfg, server, "{command}", "./run --name '{hostname}'") + + require.NoError(t, err) + require.Len(t, args, 3) + assert.Equal(t, "My Server", args[2]) +} + +func TestBuildCommandArgs_SplicesCommandTokensIntoWrapper(t *testing.T) { + cfg := fakeWorkDirReader{workDir: "/work-path"} + server := newTestServerForVars(nil, map[string]string{"hostname": "My Server"}, nil) + + args, err := BuildCommandArgs( + cfg, server, + "./wrapper --ip {ip} -- {command}", + "./run.sh +set hostname '{hostname}'", + ) + + require.NoError(t, err) + require.Equal(t, + []string{"./wrapper", "--ip", "127.0.0.1", "--", "./run.sh", "+set", "hostname", "My Server"}, + args, + ) +} + +func TestBuildCommandArgs_DoesNotReexpandSubstitutedValue(t *testing.T) { + cfg := fakeWorkDirReader{workDir: "/work-path"} + server := newTestServerForVars(nil, map[string]string{"hostname": "{id}"}, nil) + + args, err := BuildCommandArgs(cfg, server, "{command}", "./run --name {hostname}") + + require.NoError(t, err) + require.Len(t, args, 3) + assert.Equal(t, "{id}", args[2]) +} + +func TestBuildCommandArgs_EmptyServerCommandYieldsNoArguments(t *testing.T) { + cfg := fakeWorkDirReader{workDir: "/work-path"} + server := newTestServerForVars(nil, nil, nil) + + args, err := BuildCommandArgs(cfg, server, "{command}", "") + + require.NoError(t, err) + require.Empty(t, args) +} + +func TestBuildCommandArgs_EmptyWrapperYieldsNoArguments(t *testing.T) { + cfg := fakeWorkDirReader{workDir: "/work-path"} + server := newTestServerForVars(nil, nil, nil) + + args, err := BuildCommandArgs(cfg, server, "", "./run --name x") + + require.NoError(t, err) + require.Empty(t, args) +} + +func TestBuildCommandArgs_ReportsUnbalancedQuoteInTemplate(t *testing.T) { + cfg := fakeWorkDirReader{workDir: "/work-path"} + server := newTestServerForVars(nil, nil, nil) + + _, err := BuildCommandArgs(cfg, server, "{command}", "./run --name 'unterminated") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to split server command") +} diff --git a/internal/app/domain/errors.go b/internal/app/domain/errors.go deleted file mode 100644 index ca8374b..0000000 --- a/internal/app/domain/errors.go +++ /dev/null @@ -1,30 +0,0 @@ -package domain - -import ( - "strconv" - "strings" -) - -type ErrInvalidResponseFromAPI struct { - body []byte - code int -} - -func NewErrInvalidResponseFromAPI(code int, response []byte) ErrInvalidResponseFromAPI { - return ErrInvalidResponseFromAPI{ - code: code, - body: response, - } -} - -func (err ErrInvalidResponseFromAPI) Error() string { - builder := strings.Builder{} - - builder.WriteString("invalid response from api server: ") - builder.WriteString("(") - builder.WriteString(strconv.Itoa(err.code)) - builder.WriteString(") ") - builder.Write(err.body) - - return builder.String() -} diff --git a/internal/app/domain/gdaemon_task.go b/internal/app/domain/gdaemon_task.go index 0858adb..c6b6498 100644 --- a/internal/app/domain/gdaemon_task.go +++ b/internal/app/domain/gdaemon_task.go @@ -1,7 +1,6 @@ package domain import ( - "context" "sync" ) @@ -39,13 +38,6 @@ const ( GDTaskCommandExecute GDTaskCommand = "cmdexec" ) -type GDTaskRepository interface { - FindByStatus(ctx context.Context, status GDTaskStatus) ([]*GDTask, error) - FindByID(ctx context.Context, id int) (*GDTask, error) - Save(ctx context.Context, task *GDTask) error - AppendOutput(ctx context.Context, gdtask *GDTask, output []byte) error -} - type GDTask struct { server *Server statusMutex *sync.Mutex diff --git a/internal/app/domain/server_task.go b/internal/app/domain/server_task.go index c5c3360..197ca33 100644 --- a/internal/app/domain/server_task.go +++ b/internal/app/domain/server_task.go @@ -1,21 +1,10 @@ package domain import ( - "context" - "encoding/json" "sync" "time" ) -type ServerTaskStatus int - -const ( - ServerTaskStatusWaiting ServerTaskStatus = iota - ServerTaskStatusWorking - ServerTaskStatusSuccess - ServerTaskStatusFail -) - type ServerTaskCommand string const ( @@ -26,82 +15,140 @@ const ( ServerTaskReinstall ServerTaskCommand = "reinstall" ) -type ServerTaskRepository interface { - Find(ctx context.Context) ([]*ServerTask, error) - Save(ctx context.Context, task *ServerTask) error - Fail(ctx context.Context, task *ServerTask, output []byte) error -} +type ServerTaskOverlapPolicy int + +const ( + ServerTaskOverlapUnspecified ServerTaskOverlapPolicy = iota + ServerTaskOverlapSkip + ServerTaskOverlapQueue +) + +type ServerTaskCatchupPolicy int + +const ( + ServerTaskCatchupUnspecified ServerTaskCatchupPolicy = iota + ServerTaskCatchupSkip + ServerTaskCatchupRunOnce +) type ServerTask struct { + mutex *sync.Mutex + + id uint64 + serverID uint64 + nodeID uint64 + version uint64 + command ServerTaskCommand + server *Server + executeDate time.Time - server *Server - mutex *sync.Mutex - command ServerTaskCommand - id int - status ServerTaskStatus repeat int repeatPeriod time.Duration counter int + + overlapPolicy ServerTaskOverlapPolicy + catchupPolicy ServerTaskCatchupPolicy + + name string + timezone string + payload string + enabled bool + updatedAt time.Time } -func NewServerTask( - id int, - command ServerTaskCommand, - server *Server, - repeat int, - repeatPeriod time.Duration, - counter int, - executeDate time.Time, -) *ServerTask { +type ServerTaskOptions struct { + ID uint64 + ServerID uint64 + NodeID uint64 + Version uint64 + Command ServerTaskCommand + Server *Server + ExecuteDate time.Time + Repeat int + RepeatPeriod time.Duration + Counter int + OverlapPolicy ServerTaskOverlapPolicy + CatchupPolicy ServerTaskCatchupPolicy + Name string + Timezone string + Payload string + Enabled bool + UpdatedAt time.Time +} + +func NewServerTask(opts ServerTaskOptions) *ServerTask { return &ServerTask{ - id: id, - status: ServerTaskStatusWaiting, - command: command, - server: server, - repeat: repeat, - repeatPeriod: repeatPeriod, - counter: counter, - executeDate: executeDate, - mutex: &sync.Mutex{}, + mutex: &sync.Mutex{}, + id: opts.ID, + serverID: opts.ServerID, + nodeID: opts.NodeID, + version: opts.Version, + command: opts.Command, + server: opts.Server, + executeDate: opts.ExecuteDate, + repeat: opts.Repeat, + repeatPeriod: opts.RepeatPeriod, + counter: opts.Counter, + overlapPolicy: opts.OverlapPolicy, + catchupPolicy: opts.CatchupPolicy, + name: opts.Name, + timezone: opts.Timezone, + payload: opts.Payload, + enabled: opts.Enabled, + updatedAt: opts.UpdatedAt, } } -func (s ServerTask) MarshalJSON() ([]byte, error) { +func (s *ServerTask) ID() uint64 { + return s.id +} + +func (s *ServerTask) ServerID() uint64 { s.mutex.Lock() defer s.mutex.Unlock() - return json.Marshal(struct { - ExecuteDate string `json:"execute_date"` - Repeat int `json:"repeat"` - RepeatPeriodInSeconds int `json:"repeat_period"` - }{ - ExecuteDate: s.executeDate.Format("2006-01-02 15:04:05"), - Repeat: s.repeat, - RepeatPeriodInSeconds: int(s.repeatPeriod.Seconds()), - }) + return s.serverID } -func (s *ServerTask) ID() int { - return s.id +func (s *ServerTask) NodeID() uint64 { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.nodeID } -func (s *ServerTask) Status() ServerTaskStatus { - return s.status +func (s *ServerTask) Version() uint64 { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.version } func (s *ServerTask) Command() ServerTaskCommand { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.command } func (s *ServerTask) Server() *Server { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.server } func (s *ServerTask) Repeat() int { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.repeat } func (s *ServerTask) RepeatPeriod() time.Duration { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.repeatPeriod } @@ -112,6 +159,13 @@ func (s *ServerTask) ExecuteDate() time.Time { return s.executeDate } +func (s *ServerTask) SetExecuteDate(t time.Time) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.executeDate = t +} + func (s *ServerTask) Counter() int { s.mutex.Lock() defer s.mutex.Unlock() @@ -119,6 +173,55 @@ func (s *ServerTask) Counter() int { return s.counter } +func (s *ServerTask) OverlapPolicy() ServerTaskOverlapPolicy { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.overlapPolicy +} + +func (s *ServerTask) CatchupPolicy() ServerTaskCatchupPolicy { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.catchupPolicy +} + +func (s *ServerTask) Name() string { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.name +} + +func (s *ServerTask) Timezone() string { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.timezone +} + +func (s *ServerTask) Payload() string { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.payload +} + +func (s *ServerTask) Enabled() bool { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.enabled +} + +func (s *ServerTask) UpdatedAt() time.Time { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.updatedAt +} + func (s *ServerTask) IncreaseCountersAndTime() { s.mutex.Lock() defer s.mutex.Unlock() @@ -135,20 +238,63 @@ func (s *ServerTask) ProlongTime() { } func (s *ServerTask) RepeatEndlessly() bool { - return s.repeat == 0 || s.repeat == -1 + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.repeatEndlessly() } func (s *ServerTask) CanExecute() bool { s.mutex.Lock() defer s.mutex.Unlock() - return s.RepeatEndlessly() || s.repeat > s.counter + return s.canExecute() } -func (s *ServerTask) prolongTask() { - s.executeDate = s.executeDate.Add(s.repeatPeriod) +func (s *ServerTask) IsActive() bool { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.enabled && s.canExecute() +} - if s.executeDate.Before(time.Now()) { - s.executeDate = time.Now().Add(s.repeatPeriod) +// UpdateFromOptions atomically applies new fields received via gRPC delta. +// Used by the scheduler when API pushes ServerTaskDelta.upserted; preserves +// counter and mutex state. +func (s *ServerTask) UpdateFromOptions(opts ServerTaskOptions) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.serverID = opts.ServerID + s.nodeID = opts.NodeID + s.version = opts.Version + s.command = opts.Command + if opts.Server != nil { + s.server = opts.Server } + s.executeDate = opts.ExecuteDate + s.repeat = opts.Repeat + s.repeatPeriod = opts.RepeatPeriod + s.counter = opts.Counter + s.overlapPolicy = opts.OverlapPolicy + s.catchupPolicy = opts.CatchupPolicy + s.name = opts.Name + s.timezone = opts.Timezone + s.payload = opts.Payload + s.enabled = opts.Enabled + s.updatedAt = opts.UpdatedAt +} + +// repeatEndlessly, canExecute and prolongTask read mutable state directly and +// must be called with s.mutex held. +func (s *ServerTask) repeatEndlessly() bool { + return s.repeat == 0 || s.repeat == -1 +} + +func (s *ServerTask) canExecute() bool { + return s.repeatEndlessly() || s.repeat > s.counter +} + +func (s *ServerTask) prolongTask() { + s.executeDate = s.executeDate.Add(s.repeatPeriod) } diff --git a/internal/app/domain/server_task_test.go b/internal/app/domain/server_task_test.go new file mode 100644 index 0000000..d62bd10 --- /dev/null +++ b/internal/app/domain/server_task_test.go @@ -0,0 +1,141 @@ +package domain + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestServerTask_ConcurrentUpdateAndRead guards the scheduler access pattern: +// the gRPC stream applies snapshots and deltas while the scheduler tick reads +// the same task. Every getter must take the task mutex, so this fails under +// -race when one of them reads a field lock-free. +func TestServerTask_ConcurrentUpdateAndRead(t *testing.T) { + task := NewServerTask(ServerTaskOptions{ + ID: 1, + ServerID: 10, + NodeID: 20, + Version: 1, + Command: ServerTaskStart, + ExecuteDate: time.Now(), + Repeat: 5, + RepeatPeriod: time.Minute, + OverlapPolicy: ServerTaskOverlapSkip, + CatchupPolicy: ServerTaskCatchupSkip, + Name: "task", + Timezone: "UTC", + Payload: "payload", + Enabled: true, + UpdatedAt: time.Now(), + }) + + const iterations = 500 + + // Each getter gets its own goroutine: a reader calling every getter in one + // loop would order itself against the writer through the locked getters and + // hide a lock-free one from the race detector. + readers := []func(){ + func() { _ = task.ID() }, + func() { _ = task.ServerID() }, + func() { _ = task.NodeID() }, + func() { _ = task.Version() }, + func() { _ = task.Command() }, + func() { _ = task.Server() }, + func() { _ = task.Repeat() }, + func() { _ = task.RepeatPeriod() }, + func() { _ = task.ExecuteDate() }, + func() { _ = task.Counter() }, + func() { _ = task.OverlapPolicy() }, + func() { _ = task.CatchupPolicy() }, + func() { _ = task.Name() }, + func() { _ = task.Timezone() }, + func() { _ = task.Payload() }, + func() { _ = task.Enabled() }, + func() { _ = task.UpdatedAt() }, + func() { _ = task.RepeatEndlessly() }, + func() { _ = task.CanExecute() }, + func() { _ = task.IsActive() }, + } + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + + for i := range iterations { + task.UpdateFromOptions(ServerTaskOptions{ + ID: 1, + ServerID: uint64(i), + NodeID: uint64(i), + Version: uint64(i), + Command: ServerTaskRestart, + Server: &Server{}, + ExecuteDate: time.Now(), + Repeat: i, + RepeatPeriod: time.Duration(i) * time.Second, + Counter: i, + OverlapPolicy: ServerTaskOverlapQueue, + CatchupPolicy: ServerTaskCatchupRunOnce, + Name: "updated", + Timezone: "Europe/Moscow", + Payload: "updated payload", + Enabled: i%2 == 0, + UpdatedAt: time.Now(), + }) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + + for range iterations { + task.IncreaseCountersAndTime() + } + }() + + for _, read := range readers { + wg.Add(1) + go func() { + defer wg.Done() + + for range iterations { + read() + } + }() + } + + wg.Wait() +} + +func TestServerTask_IsActive(t *testing.T) { + tests := []struct { + name string + enabled bool + repeat int + counter int + expected bool + }{ + {"endless repeat", true, 0, 100, true}, + {"infinite repeat", true, -1, 100, true}, + {"repeats left", true, 3, 2, true}, + {"repeats exhausted", true, 3, 3, false}, + {"disabled", false, 0, 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task := NewServerTask(ServerTaskOptions{ + ID: 1, + Repeat: tt.repeat, + Counter: tt.counter, + Enabled: tt.enabled, + }) + + assert.Equal(t, tt.expected, task.IsActive()) + }) + } +} diff --git a/internal/app/enroll_cmd.go b/internal/app/enroll_cmd.go index 8bc8b86..9d3e931 100644 --- a/internal/app/enroll_cmd.go +++ b/internal/app/enroll_cmd.go @@ -90,8 +90,6 @@ func enrollAction(c *cli.Context) error { enrollCfg := &config.EnrollConfig{ NodeID: uint(result.NodeID), APIKey: result.APIKey, - ListenIP: listenIP, - ListenPort: listenPort, CACertificateFile: filepath.Join(certsDir, "ca.crt"), CertificateChainFile: filepath.Join(certsDir, "server.crt"), PrivateKeyFile: filepath.Join(certsDir, "server.key"), @@ -101,7 +99,6 @@ func enrollAction(c *cli.Context) error { DrivesList: []string{}, LogLevel: "info", GRPC: config.EnrollGRPC{ - Enabled: true, Address: urlInfo.Address, }, } diff --git a/internal/app/game_server_commands/install_server.go b/internal/app/game_server_commands/install_server.go index a6f08ee..43f1f26 100644 --- a/internal/app/game_server_commands/install_server.go +++ b/internal/app/game_server_commands/install_server.go @@ -252,7 +252,7 @@ func (cmd *installServer) installByScript(ctx context.Context, server *domain.Se } func (cmd *installServer) install(ctx context.Context, server *domain.Server) error { - sd := installationRulesDefiner{} + sd := installationRulesDefiner{replacements: cmd.cfg.RemoteRepositoryReplacements} game := server.Game() gameMod := server.GameMod() @@ -292,7 +292,9 @@ func (cmd *installServer) install(ctx context.Context, server *domain.Server) er return nil } -type installationRulesDefiner struct{} +type installationRulesDefiner struct { + replacements config.RepositoryReplacements +} func (d *installationRulesDefiner) DefineGameRules(game *domain.Game) []*installationRule { var rules []*installationRule @@ -305,10 +307,7 @@ func (d *installationRulesDefiner) DefineGameRules(game *domain.Game) []*install } if game.RemoteRepository != "" { - rule := d.defineRemoteRepositoryRule(game.RemoteRepository) - if rule != nil { - rules = append(rules, rule) - } + rules = append(rules, d.defineRemoteRepositoryRules(game.RemoteRepository)...) } if game.SteamAppID > 0 { @@ -346,11 +345,7 @@ func (d *installationRulesDefiner) defineLocalRepositoryRule(localRepository str return rule } -func (d *installationRulesDefiner) defineRemoteRepositoryRule(remoteRepository string) *installationRule { - rule := &installationRule{ - SourceValue: remoteRepository, - } - +func (d *installationRulesDefiner) defineRemoteRepositoryRules(remoteRepository string) []*installationRule { parsedURL, err := url.Parse(remoteRepository) if err != nil { log.Warning(err) @@ -361,9 +356,17 @@ func (d *installationRulesDefiner) defineRemoteRepositoryRule(remoteRepository s return nil } - rule.Action = downloadAnUnpackFromRemoteRepository + candidates := buildRemoteRepositoryCandidates(remoteRepository, d.replacements) - return rule + rules := make([]*installationRule, 0, len(candidates)) + for _, candidate := range candidates { + rules = append(rules, &installationRule{ + SourceValue: candidate, + Action: downloadAnUnpackFromRemoteRepository, + }) + } + + return rules } func (d *installationRulesDefiner) DefineGameModRules(gameMod *domain.GameMod) []*installationRule { @@ -377,10 +380,7 @@ func (d *installationRulesDefiner) DefineGameModRules(gameMod *domain.GameMod) [ } if gameMod.RemoteRepository != "" { - rule := d.defineRemoteRepositoryRule(gameMod.RemoteRepository) - if rule != nil { - rules = append(rules, rule) - } + rules = append(rules, d.defineRemoteRepositoryRules(gameMod.RemoteRepository)...) } return rules diff --git a/internal/app/game_server_commands/install_server_test.go b/internal/app/game_server_commands/install_server_test.go index 7fc199b..60253d6 100644 --- a/internal/app/game_server_commands/install_server_test.go +++ b/internal/app/game_server_commands/install_server_test.go @@ -1,9 +1,14 @@ package gameservercommands import ( + "archive/tar" "bytes" + "compress/gzip" "context" "io" + "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" "testing" @@ -189,6 +194,49 @@ func TestInstallationRuleDefiner_GameModInvalidRemoteRepository_ExpectLocalRepo( assert.Equal(t, copyDirectoryFromLocalRepository, rules[0].Action) } +func TestGameRulesDefiner_RemoteRepositoryWithReplacements_ExpectMirrorsThenOriginal(t *testing.T) { + rulesDefiner := installationRulesDefiner{ + replacements: config.RepositoryReplacements{ + "example.com": { + {Replace: "mirror2.example.com", Priority: 9}, + {Replace: "mirror1.example.com", Priority: 10}, + }, + }, + } + game := &domain.Game{ + RemoteRepository: "https://example.com/file.zip", + } + + rules := rulesDefiner.DefineGameRules(game) + + require.Len(t, rules, 3) + assert.Equal(t, "https://mirror1.example.com/file.zip", rules[0].SourceValue) + assert.Equal(t, downloadAnUnpackFromRemoteRepository, rules[0].Action) + assert.Equal(t, "https://mirror2.example.com/file.zip", rules[1].SourceValue) + assert.Equal(t, downloadAnUnpackFromRemoteRepository, rules[1].Action) + assert.Equal(t, "https://example.com/file.zip", rules[2].SourceValue) + assert.Equal(t, downloadAnUnpackFromRemoteRepository, rules[2].Action) +} + +func TestInstallationRuleDefiner_GameModRemoteRepositoryWithReplacements_ExpectMirrorsThenOriginal(t *testing.T) { + rulesDefiner := installationRulesDefiner{ + replacements: config.RepositoryReplacements{ + "example.com": {{Replace: "https://mirror.example.com/mods"}}, + }, + } + gameMod := &domain.GameMod{ + RemoteRepository: "https://example.com/file.zip", + } + + rules := rulesDefiner.DefineGameModRules(gameMod) + + require.Len(t, rules, 2) + assert.Equal(t, "https://mirror.example.com/mods/file.zip", rules[0].SourceValue) + assert.Equal(t, downloadAnUnpackFromRemoteRepository, rules[0].Action) + assert.Equal(t, "https://example.com/file.zip", rules[1].SourceValue) + assert.Equal(t, downloadAnUnpackFromRemoteRepository, rules[1].Action) +} + func TestInstallation_ServerInstalledFromRemoterRepository(t *testing.T) { workPath, err := os.MkdirTemp(os.TempDir(), "gameap-daemon-test") defer func(path string) { @@ -220,6 +268,59 @@ func TestInstallation_ServerInstalledFromRemoterRepository(t *testing.T) { assert.FileExists(t, workPath+"/test-server/.gamemodinstalled") } +func TestInstallation_FirstMirrorUnavailable_ServerInstalledFromNextMirror(t *testing.T) { + unavailableMirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer unavailableMirror.Close() + archive := givenTarGzArchive(t, map[string]string{"mirror_file.txt": "content from mirror"}) + workingMirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archive) + })) + defer workingMirror.Close() + workPath, err := os.MkdirTemp(os.TempDir(), "gameap-daemon-test") + defer func(path string) { + err := os.RemoveAll(path) + if err != nil { + t.Fatal(err) + } + }(workPath) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{ + WorkPath: workPath, + RemoteRepositoryReplacements: config.RepositoryReplacements{ + "files.unavailable-repo.test": { + {Replace: hostOfURL(t, unavailableMirror.URL), Priority: 10}, + {Replace: hostOfURL(t, workingMirror.URL), Priority: 9}, + }, + }, + } + install := newInstallServer( + cfg, + components.NewExecutor(), + processmanager.NewSimple(cfg, components.NewExecutor(), components.NewExecutor()), + mocks.NewServerRepository(), + commandmocks.LoadServerCommand(domain.Status), + commandmocks.LoadServerCommand(domain.Stop), + commandmocks.LoadServerCommand(domain.Start), + ) + game := domain.Game{ + StartCode: "test", + RemoteRepository: "http://files.unavailable-repo.test/game.tar.gz", + } + gameMod := domain.GameMod{Name: "test"} + + err = install.Execute(context.Background(), givenServer(t, game, gameMod)) + + require.Nil(t, err) + assert.FileExists(t, workPath+"/test-server/mirror_file.txt") + output := string(install.ReadOutput()) + assert.Contains(t, output, unavailableMirror.URL) + assert.Contains(t, output, workingMirror.URL) +} + func TestInstallation_ServerInstalledFromLocalRepository(t *testing.T) { workPath, err := os.MkdirTemp(os.TempDir(), "gameap-daemon-test") defer func(path string) { @@ -468,6 +569,48 @@ func givenServer(t *testing.T, game domain.Game, gameMod domain.GameMod) *domain ) } +func givenTarGzArchive(t *testing.T, files map[string]string) []byte { + t.Helper() + + buf := &bytes.Buffer{} + gzWriter := gzip.NewWriter(buf) + tarWriter := tar.NewWriter(gzWriter) + + for name, content := range files { + err := tarWriter.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(content)), + }) + if err != nil { + t.Fatal(err) + } + if _, err = tarWriter.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzWriter.Close(); err != nil { + t.Fatal(err) + } + + return buf.Bytes() +} + +func hostOfURL(t *testing.T, rawURL string) string { + t.Helper() + + u, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + + return u.Host +} + type testExecutor struct { command string options contracts.ExecutorOptions @@ -491,6 +634,18 @@ func (ex *testExecutor) ExecWithWriter( return 0, nil } +func (ex *testExecutor) ExecArgs( + _ context.Context, _ []string, _ contracts.ExecutorOptions, +) ([]byte, int, error) { + return []byte(""), 0, nil +} + +func (ex *testExecutor) ExecWithWriterArgs( + _ context.Context, _ []string, _ io.Writer, _ contracts.ExecutorOptions, +) (int, error) { + return 0, nil +} + func (ex *testExecutor) AssertCommand(t *testing.T, expected string) { t.Helper() diff --git a/internal/app/game_server_commands/repository_replacer.go b/internal/app/game_server_commands/repository_replacer.go new file mode 100644 index 0000000..0b979f5 --- /dev/null +++ b/internal/app/game_server_commands/repository_replacer.go @@ -0,0 +1,86 @@ +package gameservercommands + +import ( + "net/url" + "strings" + + "github.com/gameap/daemon/internal/app/config" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +// buildRemoteRepositoryCandidates expands a remote repository URL into a list +// of download candidates: replacement targets configured for the URL host in +// priority order, followed by the original URL as the last resort. +// Without matching replacements the original URL is returned as the only candidate. +func buildRemoteRepositoryCandidates( + remoteRepository string, + replacements config.RepositoryReplacements, +) []string { + originalURL, err := url.Parse(remoteRepository) + if err != nil || originalURL.Host == "" { + return []string{remoteRepository} + } + + targets := replacements.TargetsForURL(originalURL) + if len(targets) == 0 { + return []string{remoteRepository} + } + + candidates := make([]string, 0, len(targets)+1) + seen := make(map[string]struct{}, len(targets)+1) + appendCandidate := func(candidate string) { + if _, ok := seen[candidate]; ok { + return + } + seen[candidate] = struct{}{} + candidates = append(candidates, candidate) + } + + for _, target := range targets.Sorted() { + replaced, err := replaceRepositoryURL(originalURL, target) + if err != nil { + log.Warning(errors.WithMessagef( + err, + "[game_server_commands.repositoryReplacer] skipped replacement for %q", + remoteRepository, + )) + + continue + } + + appendCandidate(replaced) + } + + appendCandidate(remoteRepository) + + return candidates +} + +// replaceRepositoryURL applies a replacement target to the URL: the host is +// replaced, the scheme and the path prefix are overridden only when present +// in the target. The rest of the URL (path, query, fragment) is preserved. +func replaceRepositoryURL(originalURL *url.URL, target config.RepositoryReplacementTarget) (string, error) { + replaceURL, err := target.URL() + if err != nil { + return "", err + } + + result := *originalURL + result.Host = replaceURL.Host + + if replaceURL.Scheme != "" { + result.Scheme = replaceURL.Scheme + } + + if replaceURL.User != nil { + result.User = replaceURL.User + } + + if path := strings.TrimSuffix(replaceURL.Path, "/"); path != "" { + result.Path = path + originalURL.Path + result.RawPath = "" + } + + return result.String(), nil +} diff --git a/internal/app/game_server_commands/repository_replacer_test.go b/internal/app/game_server_commands/repository_replacer_test.go new file mode 100644 index 0000000..a245428 --- /dev/null +++ b/internal/app/game_server_commands/repository_replacer_test.go @@ -0,0 +1,170 @@ +package gameservercommands + +import ( + "testing" + + "github.com/gameap/daemon/internal/app/config" + "github.com/stretchr/testify/assert" +) + +func TestBuildRemoteRepositoryCandidates(t *testing.T) { + tests := []struct { + name string + url string + replacements config.RepositoryReplacements + expected []string + }{ + { + name: "no replacements configured", + url: "http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", + expected: []string{"http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz"}, + }, + { + name: "no replacements for the host", + url: "http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", + replacements: config.RepositoryReplacements{ + "files.example.com": {{Replace: "cdn.example.com"}}, + }, + expected: []string{"http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz"}, + }, + { + name: "host replaced, original last", + url: "http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": {{Replace: "cdn.gameap.com"}}, + }, + expected: []string{ + "http://cdn.gameap.com/cstrike-1.6/hlcs_base.tar.xz", + "http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", + }, + }, + { + name: "scheme overridden by replacement", + url: "http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": {{Replace: "https://cdn.gameap.com"}}, + }, + expected: []string{ + "https://cdn.gameap.com/cstrike-1.6/hlcs_base.tar.xz", + "http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", + }, + }, + { + name: "path prefix prepended", + url: "http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": {{Replace: "cdn.example.com/gameap-mirror/"}}, + }, + expected: []string{ + "http://cdn.example.com/gameap-mirror/cstrike-1.6/hlcs_base.tar.xz", + "http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", + }, + }, + { + name: "priority defines the order", + url: "http://files.gameap.ru/hlcs_base.tar.xz", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": { + {Replace: "cdn.gameap.ru", Priority: 9}, + {Replace: "cdn.gameap.com", Priority: 10}, + }, + }, + expected: []string{ + "http://cdn.gameap.com/hlcs_base.tar.xz", + "http://cdn.gameap.ru/hlcs_base.tar.xz", + "http://files.gameap.ru/hlcs_base.tar.xz", + }, + }, + { + name: "equal priorities keep the config order", + url: "http://files.gameap.ru/hlcs_base.tar.xz", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": { + {Replace: "cdn1.gameap.com"}, + {Replace: "cdn2.gameap.com"}, + {Replace: "cdn3.gameap.com"}, + }, + }, + expected: []string{ + "http://cdn1.gameap.com/hlcs_base.tar.xz", + "http://cdn2.gameap.com/hlcs_base.tar.xz", + "http://cdn3.gameap.com/hlcs_base.tar.xz", + "http://files.gameap.ru/hlcs_base.tar.xz", + }, + }, + { + name: "replacement with port for URL with port", + url: "http://files.gameap.ru:8080/hlcs_base.tar.xz", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": {{Replace: "cdn.gameap.com:9090"}}, + }, + expected: []string{ + "http://cdn.gameap.com:9090/hlcs_base.tar.xz", + "http://files.gameap.ru:8080/hlcs_base.tar.xz", + }, + }, + { + name: "query preserved", + url: "http://files.gameap.ru/hlcs_base.tar.xz?token=abc", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": {{Replace: "cdn.gameap.com"}}, + }, + expected: []string{ + "http://cdn.gameap.com/hlcs_base.tar.xz?token=abc", + "http://files.gameap.ru/hlcs_base.tar.xz?token=abc", + }, + }, + { + name: "invalid replacement skipped", + url: "http://files.gameap.ru/hlcs_base.tar.xz", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": { + {Replace: " ", Priority: 10}, + {Replace: "cdn.gameap.com", Priority: 9}, + }, + }, + expected: []string{ + "http://cdn.gameap.com/hlcs_base.tar.xz", + "http://files.gameap.ru/hlcs_base.tar.xz", + }, + }, + { + name: "replacement equal to the original deduplicated", + url: "http://files.gameap.ru/hlcs_base.tar.xz", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": { + {Replace: "files.gameap.ru", Priority: 10}, + {Replace: "cdn.gameap.com", Priority: 9}, + }, + }, + expected: []string{ + "http://files.gameap.ru/hlcs_base.tar.xz", + "http://cdn.gameap.com/hlcs_base.tar.xz", + }, + }, + { + name: "unparsable URL returned as is", + url: "://invalid", + replacements: config.RepositoryReplacements{ + "files.gameap.ru": {{Replace: "cdn.gameap.com"}}, + }, + expected: []string{"://invalid"}, + }, + { + name: "URL without host returned as is", + url: "invalid-value", + replacements: config.RepositoryReplacements{ + "invalid-value": {{Replace: "cdn.gameap.com"}}, + }, + expected: []string{"invalid-value"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidates := buildRemoteRepositoryCandidates(test.url, test.replacements) + + assert.Equal(t, test.expected, candidates) + }) + } +} diff --git a/internal/app/gdaemon_scheduler/const.go b/internal/app/gdaemon_scheduler/const.go index 9efce2c..4472269 100644 --- a/internal/app/gdaemon_scheduler/const.go +++ b/internal/app/gdaemon_scheduler/const.go @@ -1,5 +1,11 @@ package gdaemonscheduler +import "time" + +// predecessorMissingTimeout bounds how long a task waits for a predecessor that +// is neither queued nor tracked as completed before it is failed. +const predecessorMissingTimeout = 5 * time.Minute + const ( TaskWaiting = iota + 1 TaskWorking diff --git a/internal/app/gdaemon_scheduler/task_manager.go b/internal/app/gdaemon_scheduler/task_manager.go index 76eb100..822d3f8 100644 --- a/internal/app/gdaemon_scheduler/task_manager.go +++ b/internal/app/gdaemon_scheduler/task_manager.go @@ -18,8 +18,6 @@ import ( log "github.com/sirupsen/logrus" ) -var updateTimeout = 5 * time.Second - type TaskStatusSender interface { SendTaskStatus(taskID int, status string, message string) SendTaskOutput(taskID int, output []byte, isFinal bool) @@ -38,38 +36,36 @@ var taskServerCommandMap = map[domain.GDTaskCommand]domain.ServerCommand{ } type TaskManager struct { - lastUpdated time.Time - repository domain.GDTaskRepository executor contracts.Executor cache contracts.Cache config *config.Config serverCommandFactory *gameservercommands.ServerCommandFactory - mutex *sync.Mutex queue *taskQueue completed *completionTracker commandsInProgress sync.Map wg sync.WaitGroup - consecutiveFailures int taskStatusSender TaskStatusSender - grpcMode bool + + // predecessorWaits maps a task ID to the moment its predecessor was first + // seen missing, bounding the wait by predecessorMissingTimeout. + predecessorWaits sync.Map + predecessorMissingTimeout time.Duration } func NewTaskManager( - repository domain.GDTaskRepository, cache contracts.Cache, serverCommandFactory *gameservercommands.ServerCommandFactory, executor contracts.Executor, config *config.Config, ) *TaskManager { return &TaskManager{ - config: config, - repository: repository, - cache: cache, - queue: newTaskQueue(), - completed: newCompletionTracker(completionTrackerCapacity), - serverCommandFactory: serverCommandFactory, - mutex: &sync.Mutex{}, - executor: executor, + config: config, + cache: cache, + queue: newTaskQueue(), + completed: newCompletionTracker(completionTrackerCapacity), + serverCommandFactory: serverCommandFactory, + executor: executor, + predecessorMissingTimeout: predecessorMissingTimeout, } } @@ -77,10 +73,6 @@ func (manager *TaskManager) SetTaskStatusSender(sender TaskStatusSender) { manager.taskStatusSender = sender } -func (manager *TaskManager) SetGRPCMode(enabled bool) { - manager.grpcMode = enabled -} - func (manager *TaskManager) InsertTask(task *domain.GDTask) { manager.queue.Insert([]*domain.GDTask{task}) } @@ -96,43 +88,18 @@ func (manager *TaskManager) CancelTask(taskID int) error { } manager.queue.Remove(task) + manager.predecessorWaits.Delete(taskID) + return nil } func (manager *TaskManager) Run(ctx context.Context) error { - if !manager.grpcMode { - manager.failWorkingTaskAfterRestart(ctx) - - err := manager.updateTasksIfNeeded(ctx) - if err != nil { - logger.Logger(ctx).Error(err) - } - } - go manager.RunWorker(ctx) - updatePeriod := manager.config.TaskManager.UpdatePeriod - if updatePeriod <= 0 { - updatePeriod = 1 * time.Second - } + <-ctx.Done() + manager.wg.Wait() - updateTicker := time.NewTicker(updatePeriod) - defer updateTicker.Stop() - - for { - select { - case <-ctx.Done(): - manager.wg.Wait() - return nil - case <-updateTicker.C: - if !manager.grpcMode { - err := manager.updateTasksIfNeeded(ctx) - if err != nil { - logger.Logger(ctx).Error(err) - } - } - } - } + return nil } func (manager *TaskManager) RunWorker(ctx context.Context) { @@ -167,27 +134,6 @@ func (manager *TaskManager) Stats() domain.GDTaskStats { return stats } -func (manager *TaskManager) failWorkingTaskAfterRestart(ctx context.Context) { - workingTasks, err := manager.repository.FindByStatus(ctx, domain.GDTaskStatusWorking) - if err != nil { - logger.Logger(ctx).Error(err) - } - - for _, task := range workingTasks { - err = task.SetStatus(domain.GDTaskStatusError) - if err != nil { - logger.Logger(ctx).Error(err) - continue - } - - manager.appendTaskOutput(ctx, task, []byte("Working task failed. GameAP Daemon was restarted.")) - err = manager.repository.Save(ctx, task) - if err != nil { - logger.Logger(ctx).Error(err) - } - } -} - func (manager *TaskManager) runNext(ctx context.Context) { task := manager.queue.Next() if task == nil { @@ -215,7 +161,6 @@ func (manager *TaskManager) runNext(ctx context.Context) { return case predecessorFail: output := []byte(reason) - go manager.appendTaskOutput(ctx, task, output) manager.notifyTaskOutput(task, output, true) manager.failTask(ctx, task) case predecessorProceed: @@ -232,7 +177,6 @@ func (manager *TaskManager) runNext(ctx context.Context) { logger.Logger(ctx).WithError(err).Error("task execution failed") output := []byte(err.Error()) - go manager.appendTaskOutput(ctx, task, output) manager.notifyTaskOutput(task, output, true) manager.failTask(ctx, task) } @@ -246,15 +190,8 @@ func (manager *TaskManager) runNext(ctx context.Context) { manager.completed.Record(task.ID(), task.Status()) manager.commandsInProgress.Delete(task.ID()) + manager.predecessorWaits.Delete(task.ID()) manager.queue.Remove(task) - - if !manager.grpcMode { - err = manager.repository.Save(ctx, task) - if err != nil { - err = errors.WithMessage(err, "[gdaemon_scheduler.TaskManager] failed to save task") - logger.Error(ctx, err) - } - } } } @@ -275,6 +212,8 @@ func (manager *TaskManager) checkPredecessor( } if t := manager.queue.FindByID(runAfterID); t != nil { + manager.predecessorWaits.Delete(task.ID()) + if !t.IsComplete() { return predecessorWait, "" } @@ -282,21 +221,45 @@ func (manager *TaskManager) checkPredecessor( } if status, ok := manager.completed.Status(runAfterID); ok { + manager.predecessorWaits.Delete(task.ID()) + return manager.evaluatePredecessorStatus(ctx, runAfterID, status) } - predecessor, err := manager.repository.FindByID(ctx, runAfterID) - if err != nil { - logger.Logger(ctx).WithError(err). - Warnf("failed to fetch predecessor task %d, will retry", runAfterID) + return manager.waitForMissingPredecessor(ctx, task, runAfterID) +} + +// waitForMissingPredecessor keeps a task waiting while its predecessor is +// neither queued nor tracked as completed, which normally means the panel has +// not delivered it yet. The wait is bounded: a predecessor that never arrives +// (or was evicted from the completion tracker) would otherwise keep the task +// in the queue forever. +func (manager *TaskManager) waitForMissingPredecessor( + ctx context.Context, task *domain.GDTask, runAfterID int, +) (predecessorDecision, string) { + now := time.Now() + + value, loaded := manager.predecessorWaits.LoadOrStore(task.ID(), now) + if !loaded { + logger.Logger(ctx).Warnf( + "predecessor task %d not found in queue or completion tracker, waiting up to %s", + runAfterID, manager.predecessorMissingTimeout, + ) + return predecessorWait, "" } - if predecessor == nil { - return predecessorFail, fmt.Sprintf("predecessor task %d not found", runAfterID) + + waitingSince, ok := value.(time.Time) + if !ok || now.Sub(waitingSince) < manager.predecessorMissingTimeout { + return predecessorWait, "" } - manager.completed.Record(predecessor.ID(), predecessor.Status()) - return manager.evaluatePredecessorStatus(ctx, runAfterID, predecessor.Status()) + manager.predecessorWaits.Delete(task.ID()) + + return predecessorFail, fmt.Sprintf( + "predecessor task %d not found after waiting %s", + runAfterID, manager.predecessorMissingTimeout, + ) } func (manager *TaskManager) evaluatePredecessorStatus( @@ -332,14 +295,6 @@ func (manager *TaskManager) executeTask(ctx context.Context, task *domain.GDTask manager.notifyTaskStatus(task, "Task started") - if !manager.grpcMode { - err = manager.repository.Save(ctx, task) - if err != nil { - err = errors.WithMessage(err, "[gdaemon_scheduler.TaskManager] failed to save task") - logger.Error(ctx, err) - } - } - if task.Task() == domain.GDTaskCommandExecute { return manager.executeCommand(ctx, task) } @@ -379,7 +334,6 @@ func (manager *TaskManager) executeCommand(ctx context.Context, task *domain.GDT if err != nil { logger.Warn(ctx, err) output := []byte(err.Error()) - manager.appendTaskOutput(ctx, task, output) manager.notifyTaskOutput(task, output, true) manager.failTask(ctx, task) } @@ -423,7 +377,6 @@ func (manager *TaskManager) executeGameCommand(ctx context.Context, task *domain if err != nil { logger.Warn(ctx, err) output := append(cmdFunc.ReadOutput(), err.Error()...) - manager.appendTaskOutput(ctx, task, output) manager.notifyTaskOutput(task, output, true) manager.failTask(ctx, task) } @@ -443,6 +396,11 @@ func (manager *TaskManager) proceedTask(ctx context.Context, task *domain.GDTask output := cmd.ReadOutput() isFinal := cmd.IsComplete() + // The output is sent before the terminal status, as executeCommand and + // executeGameCommand do: the panel closes the task on the status update and + // may drop anything arriving after it. + manager.notifyTaskOutput(task, output, isFinal) + if isFinal { manager.commandsInProgress.Delete(task.ID()) @@ -457,9 +415,6 @@ func (manager *TaskManager) proceedTask(ctx context.Context, task *domain.GDTask } } - go manager.appendTaskOutput(ctx, task, output) - manager.notifyTaskOutput(task, output, isFinal) - return nil } @@ -484,49 +439,6 @@ func (manager *TaskManager) notifyTaskOutput(task *domain.GDTask, output []byte, } } -func (manager *TaskManager) appendTaskOutput(ctx context.Context, task *domain.GDTask, output []byte) { - if len(output) == 0 { - return - } - - if manager.grpcMode { - return - } - - err := manager.repository.AppendOutput(ctx, task, output) - if err != nil { - logger.Logger(ctx).Error(err) - } -} - -func (manager *TaskManager) updateTasksIfNeeded(ctx context.Context) error { - manager.mutex.Lock() - defer manager.mutex.Unlock() - - backoff := updateTimeout * time.Duration(1< 0 { - manager.queue.Insert(tasks) - } - - manager.lastUpdated = time.Now() - - return nil -} - type taskQueue struct { tasks []*domain.GDTask mutex sync.RWMutex diff --git a/internal/app/gdaemon_scheduler/tasks_manager_test.go b/internal/app/gdaemon_scheduler/tasks_manager_test.go index c973872..63d85e3 100644 --- a/internal/app/gdaemon_scheduler/tasks_manager_test.go +++ b/internal/app/gdaemon_scheduler/tasks_manager_test.go @@ -1,10 +1,13 @@ package gdaemonscheduler import ( + "context" "testing" "time" + "github.com/gameap/daemon/internal/app/config" "github.com/gameap/daemon/internal/app/domain" + gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,3 +49,119 @@ func Test_taskQueue(t *testing.T) { queue.Remove(task1) assert.Len(t, queue.tasks, 6) } + +func Test_checkPredecessor(t *testing.T) { + newManager := func(timeout time.Duration) *TaskManager { + manager := NewTaskManager(nil, nil, nil, &config.Config{}) + manager.predecessorMissingTimeout = timeout + + return manager + } + + t.Run("no predecessor proceeds", func(t *testing.T) { + manager := newManager(time.Minute) + task := domain.NewGDTask(1, 0, nil, "", "", domain.GDTaskStatusWaiting) + + decision, reason := manager.checkPredecessor(context.Background(), task) + + assert.Equal(t, predecessorProceed, decision) + assert.Empty(t, reason) + }) + + t.Run("missing predecessor waits and then fails", func(t *testing.T) { + manager := newManager(50 * time.Millisecond) + task := domain.NewGDTask(1, 2, nil, "", "", domain.GDTaskStatusWaiting) + + decision, reason := manager.checkPredecessor(context.Background(), task) + require.Equal(t, predecessorWait, decision) + require.Empty(t, reason) + + time.Sleep(60 * time.Millisecond) + + decision, reason = manager.checkPredecessor(context.Background(), task) + + assert.Equal(t, predecessorFail, decision) + assert.Contains(t, reason, "predecessor task 2 not found") + }) + + t.Run("predecessor arriving before the timeout resets the wait", func(t *testing.T) { + manager := newManager(50 * time.Millisecond) + task := domain.NewGDTask(1, 2, nil, "", "", domain.GDTaskStatusWaiting) + + decision, _ := manager.checkPredecessor(context.Background(), task) + require.Equal(t, predecessorWait, decision) + + manager.completed.Record(2, domain.GDTaskStatusSuccess) + decision, _ = manager.checkPredecessor(context.Background(), task) + require.Equal(t, predecessorProceed, decision) + + _, waiting := manager.predecessorWaits.Load(task.ID()) + assert.False(t, waiting, "the wait deadline must be dropped once the predecessor is found") + }) + + t.Run("failed predecessor fails the task", func(t *testing.T) { + manager := newManager(time.Minute) + task := domain.NewGDTask(1, 2, nil, "", "", domain.GDTaskStatusWaiting) + manager.completed.Record(2, domain.GDTaskStatusError) + + decision, reason := manager.checkPredecessor(context.Background(), task) + + assert.Equal(t, predecessorFail, decision) + assert.Contains(t, reason, "predecessor task 2 failed") + }) +} + +func Test_proceedTask_SendsFinalOutputBeforeStatus(t *testing.T) { + tests := []struct { + name string + result int + expectedStatus string + }{ + {"failed command", 1, string(domain.GDTaskStatusError)}, + {"successful command", gameservercommands.SuccessResult, string(domain.GDTaskStatusSuccess)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := NewTaskManager(nil, nil, nil, &config.Config{}) + sender := &recordingTaskStatusSender{} + manager.SetTaskStatusSender(sender) + + task := domain.NewGDTask(1, 0, nil, "", "", domain.GDTaskStatusWorking) + manager.commandsInProgress.Store(task.ID(), &completedCommand{ + output: []byte("last output line"), + result: tt.result, + }) + + require.NoError(t, manager.proceedTask(context.Background(), task)) + + require.Equal(t, []string{"output:last output line:final", "status:" + tt.expectedStatus}, sender.events) + }) + } +} + +type recordingTaskStatusSender struct { + events []string +} + +func (s *recordingTaskStatusSender) SendTaskStatus(_ int, status string, _ string) { + s.events = append(s.events, "status:"+status) +} + +func (s *recordingTaskStatusSender) SendTaskOutput(_ int, output []byte, isFinal bool) { + event := "output:" + string(output) + if isFinal { + event += ":final" + } + + s.events = append(s.events, event) +} + +type completedCommand struct { + output []byte + result int +} + +func (c *completedCommand) ReadOutput() []byte { return c.output } +func (c *completedCommand) Result() int { return c.result } +func (c *completedCommand) IsComplete() bool { return true } diff --git a/internal/app/grpc/archive_handler.go b/internal/app/grpc/archive_handler.go new file mode 100644 index 0000000..d17f233 --- /dev/null +++ b/internal/app/grpc/archive_handler.go @@ -0,0 +1,329 @@ +package grpc + +import ( + "context" + "sync" + "sync/atomic" + "time" + + daemonarchive "github.com/gameap/daemon/internal/app/archive" + pb "github.com/gameap/gameap/pkg/proto" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "golang.org/x/sync/semaphore" +) + +const ( + defaultMaxConcurrentArchives = 4 + defaultArchiveTimeout = time.Hour + defaultProgressInterval = time.Second + + // minProgressInterval floors the requested reporting rate. Every tick puts + // a message on the shared outbound channel, which drops messages once it is + // full — an unclamped interval would let progress reports starve final + // responses and task statuses. + minProgressInterval = 100 * time.Millisecond + // maxArchiveTimeout caps how long one operation may hold its slot in the + // concurrency semaphore. + maxArchiveTimeout = 24 * time.Hour + + maxSkippedEntries = 1000 +) + +// activeArchive tracks one in-flight archive operation so an ArchiveCancel can +// find it and abort its context. +type activeArchive struct { + cancel context.CancelFunc + reason atomic.Pointer[string] +} + +type archiveProgressState struct { + filesProcessed int64 + bytesProcessed int64 + currentEntry string +} + +type GRPCArchiveHandler struct { + workDir string + responseSender ResponseSender + sem *semaphore.Weighted + activeArchives sync.Map // map[string]*activeArchive +} + +func NewGRPCArchiveHandler(workDir string, responseSender ResponseSender, maxConcurrent int64) *GRPCArchiveHandler { + if maxConcurrent <= 0 { + maxConcurrent = defaultMaxConcurrentArchives + } + + return &GRPCArchiveHandler{ + workDir: workDir, + responseSender: responseSender, + sem: semaphore.NewWeighted(maxConcurrent), + } +} + +// HandleArchiveRequest handles an archive create/extract request from the API. +// The operation runs in the background; progress and the single final +// ArchiveResponse are delivered through the response sender. +func (h *GRPCArchiveHandler) HandleArchiveRequest(ctx context.Context, req *pb.ArchiveRequest) { + requestID := req.GetRequestId() + l := log.WithField("request_id", requestID) + + if requestID == "" { + l.Error("Archive request with empty request_id, dropping") + return + } + + format := archiveRequestFormat(req) + + if req.GetExtract() == nil && req.GetCreate() == nil { + l.Warn("Archive request without extract or create operation") + h.sendResponse(&pb.ArchiveResponse{ + RequestId: requestID, + Error: "extract or create operation required", + Format: format, + }) + return + } + + timeout := req.GetTimeout().AsDuration() + if timeout <= 0 { + timeout = defaultArchiveTimeout + } + if timeout > maxArchiveTimeout { + timeout = maxArchiveTimeout + } + + opCtx, cancel := context.WithTimeout(ctx, timeout) + entry := &activeArchive{cancel: cancel} + + // Registered synchronously so an ArchiveCancel arriving right after this + // request still finds the operation; the goroutine removes the entry. + if _, loaded := h.activeArchives.LoadOrStore(requestID, entry); loaded { + cancel() + l.Warn("Archive request already active, rejecting duplicate") + h.sendResponse(&pb.ArchiveResponse{ + RequestId: requestID, + Error: "archive request already active: " + requestID, + Format: format, + }) + return + } + + l.Info("Handling archive request") + + go h.run(opCtx, entry, requestID, req, format, l) +} + +// HandleArchiveCancel cancels an active archive operation. No response is sent +// here: the operation itself answers with the final ArchiveResponse. +func (h *GRPCArchiveHandler) HandleArchiveCancel(_ context.Context, cancel *pb.ArchiveCancel) { + requestID := cancel.GetRequestId() + + v, ok := h.activeArchives.Load(requestID) + if !ok { + log.WithField("request_id", requestID).Warn("Archive cancel for unknown request") + return + } + + entry := v.(*activeArchive) + if reason := cancel.GetReason(); reason != "" { + entry.reason.Store(&reason) + } + entry.cancel() +} + +func (h *GRPCArchiveHandler) run( + ctx context.Context, + entry *activeArchive, + requestID string, + req *pb.ArchiveRequest, + format pb.ArchiveFormat, + l *log.Entry, +) { + defer h.activeArchives.Delete(requestID) + defer entry.cancel() + + if err := h.sem.Acquire(ctx, 1); err != nil { + l.WithError(err).Warn("Failed to acquire archive semaphore") + h.sendErrorResponse(ctx, entry, requestID, format, err) + return + } + defer h.sem.Release(1) + + var progress atomic.Pointer[archiveProgressState] + progressFn := func(filesProcessed, bytesProcessed int64, currentEntry string) { + progress.Store(&archiveProgressState{ + filesProcessed: filesProcessed, + bytesProcessed: bytesProcessed, + currentEntry: currentEntry, + }) + } + + progressInterval := req.GetProgressInterval().AsDuration() + if progressInterval <= 0 { + progressInterval = defaultProgressInterval + } + if progressInterval < minProgressInterval { + progressInterval = minProgressInterval + } + + progressDone := make(chan struct{}) + progressStopped := make(chan struct{}) + go h.progressLoop(ctx, progressDone, progressStopped, progressInterval, requestID, &progress) + + // Waits for the reporter to actually stop, not just to be told to: the proto + // promises a single final response that ends the operation, and a progress + // message queued after it would reopen an operation the API considers done. + stopProgress := sync.OnceFunc(func() { + close(progressDone) + <-progressStopped + }) + + // Registered last so it runs first (LIFO): the failure response goes out + // before entry.cancel() marks the context canceled, and the remaining + // defers (sem release, cancel, registry delete) still run after recover. + // Registering it only here — after the reporter exists — is what lets the + // panic path join the reporter before it answers, same as the normal one. + defer func() { + if r := recover(); r != nil { + stopProgress() + + err := errors.Errorf("archive operation panicked: %v", r) + l.WithError(err).Error("Archive operation panicked") + h.sendErrorResponse(ctx, entry, requestID, format, err) + } + }() + + var result *daemonarchive.Result + var err error + if create := req.GetCreate(); create != nil { + l.WithField("archive_path", create.GetArchivePath()).Info("Creating archive") + result, err = daemonarchive.Create(ctx, h.workDir, create, progressFn) + } else { + extract := req.GetExtract() + l.WithField("archive_path", extract.GetArchivePath()).Info("Extracting archive") + result, err = daemonarchive.Extract(ctx, h.workDir, extract, progressFn) + } + + stopProgress() + + if err != nil { + l.WithError(err).Warn("Archive operation failed") + h.sendErrorResponse(ctx, entry, requestID, format, err) + return + } + + skipped := result.Skipped + if len(skipped) > maxSkippedEntries { + skipped = skipped[:maxSkippedEntries] + } + + // The proto asks for the format the daemon actually used, which is the + // resolved one when the request left it unspecified. + if result.Format != pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + format = result.Format + } + + h.sendResponse(&pb.ArchiveResponse{ + RequestId: requestID, + Success: true, + FilesProcessed: uint32(result.FilesProcessed), + BytesProcessed: uint64(result.BytesProcessed), + ArchiveSize: uint64(result.ArchiveSize), + Skipped: skipped, + SkippedCount: uint32(len(result.Skipped)), + Format: format, + }) + + l.WithFields(log.Fields{ + "files_processed": result.FilesProcessed, + "bytes_processed": result.BytesProcessed, + "archive_size": result.ArchiveSize, + "format": format, + }).Info("Archive operation completed") +} + +// progressLoop reports the last progress snapshot on every tick until done is +// closed, then closes stopped. Totals stay at zero (unknown) as the proto +// allows. +func (h *GRPCArchiveHandler) progressLoop( + ctx context.Context, + done, stopped chan struct{}, + interval time.Duration, + requestID string, + progress *atomic.Pointer[archiveProgressState], +) { + defer close(stopped) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + msg := &pb.ArchiveProgress{RequestId: requestID} + if state := progress.Load(); state != nil { + msg.FilesProcessed = uint32(state.filesProcessed) + msg.BytesProcessed = uint64(state.bytesProcessed) + msg.CurrentEntry = state.currentEntry + } + h.responseSender.Send(&pb.DaemonMessage{ + RequestId: requestID, + Payload: &pb.DaemonMessage_ArchiveProgress{ + ArchiveProgress: msg, + }, + }) + } + } +} + +func (h *GRPCArchiveHandler) sendErrorResponse( + ctx context.Context, + entry *activeArchive, + requestID string, + format pb.ArchiveFormat, + err error, +) { + errMsg := err.Error() + + switch { + case errors.Is(ctx.Err(), context.Canceled): + errMsg = "canceled" + if reason := entry.reason.Load(); reason != nil && *reason != "" { + errMsg = "canceled: " + *reason + } + case errors.Is(ctx.Err(), context.DeadlineExceeded): + errMsg = "timeout exceeded" + } + + h.sendResponse(&pb.ArchiveResponse{ + RequestId: requestID, + Error: errMsg, + Format: format, + }) +} + +func (h *GRPCArchiveHandler) sendResponse(resp *pb.ArchiveResponse) { + h.responseSender.Send(&pb.DaemonMessage{ + RequestId: resp.RequestId, + Payload: &pb.DaemonMessage_ArchiveResponse{ + ArchiveResponse: resp, + }, + }) +} + +func archiveRequestFormat(req *pb.ArchiveRequest) pb.ArchiveFormat { + if create := req.GetCreate(); create != nil { + return create.GetFormat() + } + if extract := req.GetExtract(); extract != nil { + return extract.GetFormat() + } + + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED +} diff --git a/internal/app/grpc/archive_handler_test.go b/internal/app/grpc/archive_handler_test.go new file mode 100644 index 0000000..c0cf683 --- /dev/null +++ b/internal/app/grpc/archive_handler_test.go @@ -0,0 +1,355 @@ +package grpc + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" + + pb "github.com/gameap/gameap/pkg/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" +) + +type fakeSender struct { + mu sync.Mutex + msgs []*pb.DaemonMessage + + // panicOnResponse makes the first ArchiveResponse blow up inside Send. It + // is the one fault a test can inject on the operation's own goroutine, + // which is what the handler recovers from. + panicOnResponse bool + panicked bool +} + +func (f *fakeSender) Send(msg *pb.DaemonMessage) { + f.mu.Lock() + defer f.mu.Unlock() + + if f.panicOnResponse && !f.panicked && msg.GetArchiveResponse() != nil { + f.panicked = true + + panic("injected sender failure") + } + + f.msgs = append(f.msgs, msg) +} + +func (f *fakeSender) archiveResponses(requestID string) []*pb.ArchiveResponse { + f.mu.Lock() + defer f.mu.Unlock() + + var out []*pb.ArchiveResponse + for _, m := range f.msgs { + if r := m.GetArchiveResponse(); r != nil && r.GetRequestId() == requestID { + out = append(out, r) + } + } + + return out +} + +func (f *fakeSender) allProgress() []*pb.ArchiveProgress { + f.mu.Lock() + defer f.mu.Unlock() + + var out []*pb.ArchiveProgress + for _, m := range f.msgs { + if p := m.GetArchiveProgress(); p != nil { + out = append(out, p) + } + } + + return out +} + +func (f *fakeSender) messageCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.msgs) +} + +func (f *fakeSender) waitFinalResponse(t *testing.T, requestID string) *pb.ArchiveResponse { + t.Helper() + return f.waitFinalResponses(t, requestID, 1)[0] +} + +func (f *fakeSender) waitFinalResponses(t *testing.T, requestID string, n int) []*pb.ArchiveResponse { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + for { + resps := f.archiveResponses(requestID) + if len(resps) >= n { + return resps + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %d final ArchiveResponse(s) for %q, got %d", n, requestID, len(resps)) + } + time.Sleep(10 * time.Millisecond) + } +} + +func setupArchiveWorkDir(t *testing.T, files int) string { + t.Helper() + + workDir := t.TempDir() + srcDir := filepath.Join(workDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0o755)) + + for i := 0; i < files; i++ { + name := filepath.Join(srcDir, fmt.Sprintf("file_%04d.txt", i)) + content := fmt.Sprintf("content of file %d\n", i) + require.NoError(t, os.WriteFile(name, []byte(content), 0o644)) + } + + return workDir +} + +func createArchiveRequest(requestID, archivePath string) *pb.ArchiveRequest { + return &pb.ArchiveRequest{ + RequestId: requestID, + Operation: &pb.ArchiveRequest_Create{ + Create: &pb.CreateArchiveParams{ + ArchivePath: archivePath, + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + BasePath: "src", + Sources: []string{"."}, + }, + }, + } +} + +func TestGRPCArchiveHandler_CreateZip(t *testing.T) { + workDir := setupArchiveWorkDir(t, 2) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + req := createArchiveRequest("create-1", "out.zip") + req.ProgressInterval = durationpb.New(time.Millisecond) + h.HandleArchiveRequest(context.Background(), req) + + resp := sender.waitFinalResponse(t, "create-1") + require.True(t, resp.Success, resp.Error) + assert.GreaterOrEqual(t, resp.FilesProcessed, uint32(2)) + assert.Greater(t, resp.ArchiveSize, uint64(0)) + assert.Equal(t, pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, resp.Format) + + for _, p := range sender.allProgress() { + assert.Equal(t, "create-1", p.GetRequestId()) + } +} + +// TestGRPCArchiveHandler_ProgressStopsBeforeResponse pins the proto rule that a +// single final response ends the operation: no progress may be queued after it, +// which means the reporter has to be joined, not merely signalled. +func TestGRPCArchiveHandler_ProgressStopsBeforeResponse(t *testing.T) { + workDir := setupArchiveWorkDir(t, 300) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + req := createArchiveRequest("progress-1", "out.zip") + req.ProgressInterval = durationpb.New(time.Nanosecond) + h.HandleArchiveRequest(context.Background(), req) + + resp := sender.waitFinalResponse(t, "progress-1") + require.True(t, resp.Success, resp.Error) + + // Give a straggling reporter a chance to show up before asserting. + time.Sleep(200 * time.Millisecond) + + sender.mu.Lock() + defer sender.mu.Unlock() + + seenResponse := false + for _, m := range sender.msgs { + if m.GetRequestId() != "progress-1" { + continue + } + if m.GetArchiveResponse() != nil { + seenResponse = true + + continue + } + if m.GetArchiveProgress() != nil { + assert.False(t, seenResponse, "progress must not be sent after the final response") + } + } + assert.True(t, seenResponse) +} + +// TestGRPCArchiveHandler_PanicAnsweredGracefully drives the recover branch in +// run: a panicking operation must still leave the API with one failed response +// and nothing queued behind it, instead of a request that never ends and a +// daemon that dies with it. +func TestGRPCArchiveHandler_PanicAnsweredGracefully(t *testing.T) { + workDir := setupArchiveWorkDir(t, 100) + + sender := &fakeSender{panicOnResponse: true} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + req := createArchiveRequest("panic-1", "panic.zip") + req.ProgressInterval = durationpb.New(time.Millisecond) + h.HandleArchiveRequest(context.Background(), req) + + resp := sender.waitFinalResponse(t, "panic-1") + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "panicked") + + // Give a reporter that outlived the recovery a chance to show up. + time.Sleep(200 * time.Millisecond) + + sender.mu.Lock() + defer sender.mu.Unlock() + + seenResponse := false + for _, m := range sender.msgs { + if m.GetRequestId() != "panic-1" { + continue + } + if m.GetArchiveResponse() != nil { + seenResponse = true + + continue + } + if m.GetArchiveProgress() != nil { + assert.False(t, seenResponse, "progress must not be sent after the final response") + } + } + assert.True(t, seenResponse) +} + +func TestGRPCArchiveHandler_ExtractMissingArchive(t *testing.T) { + workDir := t.TempDir() + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + h.HandleArchiveRequest(context.Background(), &pb.ArchiveRequest{ + RequestId: "extract-1", + Operation: &pb.ArchiveRequest_Extract{ + Extract: &pb.ExtractArchiveParams{ + ArchivePath: "missing.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, + }, + }) + + resp := sender.waitFinalResponse(t, "extract-1") + assert.False(t, resp.Success) + assert.NotEmpty(t, resp.Error) +} + +// TestGRPCArchiveHandler_Cancel pins the cancel path: a request is registered +// before its goroutine runs, so a cancel arriving right behind it finds the +// operation and turns it into the one failed response, reason included. The +// test holds the handler's only semaphore slot, which is what keeps the +// operation registered until the cancel lands — sizing the source tree and +// hoping the archiving outlasts the cancel would be a race. +func TestGRPCArchiveHandler_Cancel(t *testing.T) { + workDir := setupArchiveWorkDir(t, 2) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 1) + ctx := context.Background() + + require.NoError(t, h.sem.Acquire(ctx, 1)) + defer h.sem.Release(1) + + h.HandleArchiveRequest(ctx, createArchiveRequest("cancel-1", "cancel.zip")) + h.HandleArchiveCancel(ctx, &pb.ArchiveCancel{RequestId: "cancel-1", Reason: "test"}) + + resp := sender.waitFinalResponse(t, "cancel-1") + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "canceled: test") +} + +// TestGRPCArchiveHandler_DuplicateRejected pins that a second request reusing a +// live request_id is rejected on the spot and leaves the operation it collided +// with running. Holding the only semaphore slot keeps the first request in the +// registry while the duplicate arrives; releasing it lets that request finish. +func TestGRPCArchiveHandler_DuplicateRejected(t *testing.T) { + workDir := setupArchiveWorkDir(t, 2) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 1) + ctx := context.Background() + + require.NoError(t, h.sem.Acquire(ctx, 1)) + + h.HandleArchiveRequest(ctx, createArchiveRequest("dup-1", "dup.zip")) + h.HandleArchiveRequest(ctx, createArchiveRequest("dup-1", "dup2.zip")) + + // The second call answers on the caller's goroutine, so while the first + // request waits for a slot its rejection is the only response that exists. + resps := sender.archiveResponses("dup-1") + require.Len(t, resps, 1, "expected an immediate 'already active' rejection") + assert.False(t, resps[0].Success) + assert.Contains(t, resps[0].Error, "already active") + assert.Contains(t, resps[0].Error, "dup-1") + + h.sem.Release(1) + + resps = sender.waitFinalResponses(t, "dup-1", 2) + assert.True(t, resps[1].Success, "the duplicate must not disturb the request it collided with: %s", resps[1].Error) +} + +func TestGRPCArchiveHandler_CancelUnknown(t *testing.T) { + sender := &fakeSender{} + h := NewGRPCArchiveHandler(t.TempDir(), sender, 4) + + h.HandleArchiveCancel(context.Background(), &pb.ArchiveCancel{RequestId: "nope", Reason: "x"}) + + time.Sleep(100 * time.Millisecond) + assert.Equal(t, 0, sender.messageCount()) +} + +func TestGRPCArchiveHandler_NoOperation(t *testing.T) { + sender := &fakeSender{} + h := NewGRPCArchiveHandler(t.TempDir(), sender, 4) + + h.HandleArchiveRequest(context.Background(), &pb.ArchiveRequest{RequestId: "noop-1"}) + + resp := sender.waitFinalResponse(t, "noop-1") + assert.False(t, resp.Success) + assert.Equal(t, "extract or create operation required", resp.Error) +} + +func TestGRPCArchiveHandler_EmptyRequestIDDropped(t *testing.T) { + sender := &fakeSender{} + h := NewGRPCArchiveHandler(t.TempDir(), sender, 4) + + h.HandleArchiveRequest(context.Background(), &pb.ArchiveRequest{ + Operation: &pb.ArchiveRequest_Create{ + Create: &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, + }, + }) + + time.Sleep(300 * time.Millisecond) + assert.Equal(t, 0, sender.messageCount()) +} + +func TestGRPCArchiveHandler_Timeout(t *testing.T) { + workDir := setupArchiveWorkDir(t, 100) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + req := createArchiveRequest("timeout-1", "timeout.zip") + req.Timeout = durationpb.New(time.Nanosecond) + h.HandleArchiveRequest(context.Background(), req) + + resp := sender.waitFinalResponse(t, "timeout-1") + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "timeout exceeded") +} diff --git a/internal/app/grpc/client.go b/internal/app/grpc/client.go index 3c39ff1..eb5fcc8 100644 --- a/internal/app/grpc/client.go +++ b/internal/app/grpc/client.go @@ -2,6 +2,7 @@ package grpc import ( "context" + "fmt" "sync" "sync/atomic" "time" @@ -12,12 +13,21 @@ import ( pb "github.com/gameap/gameap/pkg/proto" "github.com/pkg/errors" log "github.com/sirupsen/logrus" + "golang.org/x/sync/semaphore" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/timestamppb" ) const ( outboundBufferSize = 500 + + // maxConcurrentFileOperations caps the file operations running off the + // receive loop. One of them can read whole files (hashing) or a whole tree + // (copy, delete), so without a cap the panel alone decides how much disk I/O + // the daemon does at once — the transfer and archive handlers bound their + // own work for the same reason. It sits above their caps because the cheap + // metadata operations share this one. + maxConcurrentFileOperations = 8 ) type TaskHandler interface { @@ -25,6 +35,14 @@ type TaskHandler interface { HandleTaskCancel(ctx context.Context, cancel *pb.TaskCancel) error } +type ServerTaskFlow interface { + ApplySnapshot(snap *pb.ServerTaskSnapshot) + ApplyDelta(delta *pb.ServerTaskDelta) + CancelExecution(req *pb.ServerTaskExecutionCancel) + AckExecution(ack *pb.ServerTaskExecutionAck) + InFlightExecutions() []*pb.InFlightServerTaskExecution +} + type CommandHandler interface { HandleCommand(ctx context.Context, requestID string, cmd *pb.CommandRequest) (*pb.CommandResult, error) } @@ -50,6 +68,11 @@ type TransferHandler interface { HandleFileDownloadTask(ctx context.Context, requestID string, task *pb.FileDownloadTask) } +type ArchiveHandler interface { + HandleArchiveRequest(ctx context.Context, req *pb.ArchiveRequest) + HandleArchiveCancel(ctx context.Context, cancel *pb.ArchiveCancel) +} + type AttachHandler interface { HandleAttachRequest(ctx context.Context, req *pb.AttachRequest) HandleAttachInput(ctx context.Context, input *pb.AttachInput) @@ -85,10 +108,12 @@ type GatewayClient struct { mu sync.RWMutex taskHandler TaskHandler + serverTaskFlow ServerTaskFlow commandHandler CommandHandler fileHandler FileHandler serverHandler ServerHandler transferHandler TransferHandler + archiveHandler ArchiveHandler attachHandler AttachHandler consoleLogHandler ConsoleLogHandler httpProxyHandler HTTPProxyHandler @@ -106,6 +131,7 @@ type GatewayClient struct { shutdown chan struct{} wg sync.WaitGroup shutdownDelay atomic.Pointer[time.Duration] + fileOpSem *semaphore.Weighted } func NewGatewayClient( @@ -136,6 +162,7 @@ func NewGatewayClient( onlineServerCounter: onlineServerCounter, outbound: make(chan *pb.DaemonMessage, outboundBufferSize), shutdown: make(chan struct{}), + fileOpSem: semaphore.NewWeighted(maxConcurrentFileOperations), } } @@ -178,14 +205,23 @@ func (c *GatewayClient) register(ctx context.Context) error { inFlightTasks = c.inFlightTaskProvider.InFlightTasks() } + var inFlightServerTaskExecutions []*pb.InFlightServerTaskExecution + if c.serverTaskFlow != nil { + inFlightServerTaskExecutions = c.serverTaskFlow.InFlightExecutions() + } + registerReq := &pb.DaemonMessage{ Payload: &pb.DaemonMessage_Register{ Register: &pb.RegisterRequest{ - NodeId: uint64(c.cfg.NodeID), - ApiKey: c.cfg.APIKey, - Version: build.Version, - Capabilities: []string{"grpc", "file_transfer", "server_status", "attach", "http_proxy", "metrics"}, - InFlightTasks: inFlightTasks, + NodeId: uint64(c.cfg.NodeID), + ApiKey: c.cfg.APIKey, + Version: build.Version, + Capabilities: []string{ + "grpc", "file_transfer", "server_status", "attach", "http_proxy", "metrics", "archive", + }, + InFlightTasks: inFlightTasks, + ServerTaskSnapshotVersion: 0, + InFlightServerTaskExecutions: inFlightServerTaskExecutions, }, }, } @@ -258,6 +294,10 @@ func (c *GatewayClient) processRegisterAck(ctx context.Context, ack *pb.Register Warn("Failed to queue pending task from RegisterAck") } } + + if ack.ServerTaskSnapshot != nil && c.serverTaskFlow != nil { + c.serverTaskFlow.ApplySnapshot(ack.ServerTaskSnapshot) + } } func groupSettingsByServerID(settings []*pb.ServerSetting) map[uint64][]*pb.ServerSetting { @@ -362,6 +402,26 @@ func (c *GatewayClient) handleMessage(ctx context.Context, msg *pb.GatewayMessag log.WithError(err).Error("Failed to handle task cancel") } + case *pb.GatewayMessage_ServerTaskSnapshot: + if c.serverTaskFlow != nil { + c.serverTaskFlow.ApplySnapshot(payload.ServerTaskSnapshot) + } + + case *pb.GatewayMessage_ServerTaskDelta: + if c.serverTaskFlow != nil { + c.serverTaskFlow.ApplyDelta(payload.ServerTaskDelta) + } + + case *pb.GatewayMessage_ServerTaskExecutionCancel: + if c.serverTaskFlow != nil { + c.serverTaskFlow.CancelExecution(payload.ServerTaskExecutionCancel) + } + + case *pb.GatewayMessage_ServerTaskExecutionAck: + if c.serverTaskFlow != nil { + c.serverTaskFlow.AckExecution(payload.ServerTaskExecutionAck) + } + case *pb.GatewayMessage_Command: resp, err := c.commandHandler.HandleCommand(ctx, msg.RequestId, payload.Command) if err != nil { @@ -435,16 +495,7 @@ func (c *GatewayClient) handleMessage(ctx context.Context, msg *pb.GatewayMessag c.handleShutdownMessage(payload.Shutdown) case *pb.GatewayMessage_FileOperation: - resp, err := c.fileHandler.HandleFileOperation(ctx, payload.FileOperation) - if err != nil { - log.WithError(err).Error("Failed to handle file operation") - return - } - c.Send(&pb.DaemonMessage{ - Payload: &pb.DaemonMessage_FileOperationResponse{ - FileOperationResponse: resp, - }, - }) + c.runFileOperation(ctx, payload.FileOperation) case *pb.GatewayMessage_FileUploadTask: c.runFileTransfer("FileUploadTask", func() { @@ -456,6 +507,16 @@ func (c *GatewayClient) handleMessage(ctx context.Context, msg *pb.GatewayMessag c.transferHandler.HandleFileDownloadTask(ctx, msg.RequestId, payload.FileDownloadTask) }) + case *pb.GatewayMessage_Archive: + c.runArchiveOp("ArchiveRequest", func() { + c.archiveHandler.HandleArchiveRequest(ctx, payload.Archive) + }) + + case *pb.GatewayMessage_ArchiveCancel: + if c.archiveHandler != nil { + c.archiveHandler.HandleArchiveCancel(ctx, payload.ArchiveCancel) + } + case *pb.GatewayMessage_AttachRequest: if c.attachHandler != nil { c.attachHandler.HandleAttachRequest(ctx, payload.AttachRequest) @@ -534,6 +595,34 @@ func (c *GatewayClient) handleServerConfigBatch(ctx context.Context, batch *pb.S } } +// runFileOperation answers a file operation off the receive loop: one can be +// arbitrarily long (hashing walks whole files), and blocking there would stall +// every other gateway message — task dispatch, cancels, shutdown. The semaphore +// is taken on the operation's own goroutine so the loop keeps moving while the +// work behind it stays bounded. +func (c *GatewayClient) runFileOperation(ctx context.Context, req *pb.FileOperationRequest) { + go func() { + if err := c.fileOpSem.Acquire(ctx, 1); err != nil { + log.WithError(err).WithField("request_id", req.GetRequestId()). + Warn("Failed to acquire file operation semaphore") + + return + } + defer c.fileOpSem.Release(1) + + resp, err := c.fileHandler.HandleFileOperation(ctx, req) + if err != nil { + log.WithError(err).Error("Failed to handle file operation") + return + } + c.Send(&pb.DaemonMessage{ + Payload: &pb.DaemonMessage_FileOperationResponse{ + FileOperationResponse: resp, + }, + }) + }() +} + func (c *GatewayClient) runFileTransfer(name string, fn func()) { if c.transferHandler == nil { log.Warnf("%s received but no transfer handler configured", name) @@ -542,6 +631,14 @@ func (c *GatewayClient) runFileTransfer(name string, fn func()) { go fn() } +func (c *GatewayClient) runArchiveOp(name string, fn func()) { + if c.archiveHandler == nil { + log.Warnf("%s received but no archive handler configured", name) + return + } + go fn() +} + func (c *GatewayClient) handleShutdownMessage(shutdown *pb.ShutdownNotification) { log.WithField("reason", shutdown.Reason). WithField("reconnect_delay", shutdown.ReconnectDelay). @@ -626,7 +723,12 @@ func (c *GatewayClient) Send(msg *pb.DaemonMessage) { select { case c.outbound <- msg: default: - log.Warn("Outbound buffer full, dropping message") + // A dropped response leaves the API-side request hanging until its + // dispatch timeout, so this must be visible at the default log level. + log.WithFields(log.Fields{ + "request_id": msg.GetRequestId(), + "payload_type": fmt.Sprintf("%T", msg.GetPayload()), + }).Error("Outbound buffer full, dropping message") } } @@ -711,6 +813,10 @@ func (c *GatewayClient) SetTransferHandler(h TransferHandler) { c.transferHandler = h } +func (c *GatewayClient) SetArchiveHandler(h ArchiveHandler) { + c.archiveHandler = h +} + func (c *GatewayClient) SetAttachHandler(h AttachHandler) { c.attachHandler = h } @@ -723,6 +829,10 @@ func (c *GatewayClient) SetHTTPProxyHandler(h HTTPProxyHandler) { c.httpProxyHandler = h } +func (c *GatewayClient) SetServerTaskFlow(f ServerTaskFlow) { + c.serverTaskFlow = f +} + func (c *GatewayClient) SetMetricsHandler(h MetricsHandler) { c.metricsHandler = h } diff --git a/internal/app/grpc/connection.go b/internal/app/grpc/connection.go index 783c87d..fb9f54b 100644 --- a/internal/app/grpc/connection.go +++ b/internal/app/grpc/connection.go @@ -74,7 +74,7 @@ func (cm *ConnectionManager) Run(ctx context.Context) error { func (cm *ConnectionManager) connectAndRun(ctx context.Context) error { var dialOpt grpc.DialOption - if cm.cfg.IsInsecure() || cm.cfg.GRPC.Insecure { + if cm.cfg.IsInsecure() { log.Warn("gRPC connection is running without TLS. It is recommended to enable TLS for security") dialOpt = grpc.WithTransportCredentials(insecure.NewCredentials()) } else { diff --git a/internal/app/grpc/file_handler.go b/internal/app/grpc/file_handler.go index 2784c70..ed42f8f 100644 --- a/internal/app/grpc/file_handler.go +++ b/internal/app/grpc/file_handler.go @@ -2,6 +2,7 @@ package grpc import ( "context" + "encoding/hex" "io" "io/fs" "os" @@ -19,6 +20,11 @@ import ( const ( defaultFileChunkSize = 64 * 1024 maxFileSize = 100 * 1024 * 1024 + + // maxHashPaths caps one hash request. Each path costs a full file read, and + // every result is carried in a single response message, so an unbounded + // list is both a work amplifier and a way to outgrow the gRPC frame limit. + maxHashPaths = 1000 ) type GRPCFileHandler struct { @@ -167,7 +173,7 @@ func (h *GRPCFileHandler) HandleFileWrite( } } - mode := os.FileMode(req.Mode) + mode := permMode(req.Mode) if mode == 0 { mode = 0644 } @@ -242,10 +248,27 @@ func listFlat(root *os.Root, rel, requestPath, pattern string) ([]*pb.FileStat, } func listRecursive(root *os.Root, rel, requestPath, pattern string) ([]*pb.FileStat, error) { + // fs.WalkDir reports a missing start directory through the callback, which + // swallows it below along with unreadable entries, so a non-existent path + // would answer with an empty success. Stat first to fail explicitly. + rootInfo, err := fs.Stat(root.FS(), rel) + if err != nil { + return nil, err + } + if !rootInfo.IsDir() { + // A file start path walks a single entry that relUnder drops, which would + // also answer with an empty success. The flat listing fails here, so match it. + return nil, errors.Errorf("path %q is not a directory", requestPath) + } + var files []*pb.FileStat - err := fs.WalkDir(root.FS(), rel, func(name string, d fs.DirEntry, walkErr error) error { + err = fs.WalkDir(root.FS(), rel, func(name string, d fs.DirEntry, walkErr error) error { if walkErr != nil { + if name == rel { + return walkErr + } + return nil //nolint:nilerr // skip unreadable entries } @@ -294,6 +317,14 @@ func relUnder(base, name string) (string, bool) { return name[len(prefix):], true } +// permMode keeps only the permission bits of a caller-supplied mode. Go maps +// os.ModeSetuid/Setgid/Sticky onto the real S_ISUID/S_ISGID/S_ISVTX bits, and +// umask does not strip them, so an unmasked mode would let an API caller ask a +// root daemon to create a setuid file inside a game-server directory. +func permMode(mode int32) os.FileMode { + return os.FileMode(mode).Perm() //nolint:gosec // masked to 0777 by Perm +} + func fileOpErrResp(requestID string, err error) (*pb.FileOperationResponse, error) { return &pb.FileOperationResponse{ RequestId: requestID, @@ -310,7 +341,7 @@ func fileOpOkResp(requestID string) (*pb.FileOperationResponse, error) { } func (h *GRPCFileHandler) HandleFileOperation( - _ context.Context, req *pb.FileOperationRequest, + ctx context.Context, req *pb.FileOperationRequest, ) (*pb.FileOperationResponse, error) { rid := req.GetRequestId() @@ -382,7 +413,7 @@ func (h *GRPCFileHandler) HandleFileOperation( if relErr != nil { return fileOpErrResp(rid, relErr) } - if err := root.Chmod(rel, os.FileMode(p.GetMode())); err != nil { + if err := root.Chmod(rel, permMode(p.GetMode())); err != nil { return fileOpErrResp(rid, err) } return fileOpOkResp(rid) @@ -407,6 +438,9 @@ func (h *GRPCFileHandler) HandleFileOperation( case pb.FileOperationType_FILE_OPERATION_TYPE_TOUCH: return h.handleTouchOp(root, rid, req.GetTouchParams()) + case pb.FileOperationType_FILE_OPERATION_TYPE_HASH: + return h.handleHashOp(ctx, root, rid, req.GetHashParams()) + default: return fileOpErrResp(rid, errors.Errorf("unsupported file operation: %s", req.GetOperation())) } @@ -490,7 +524,7 @@ func (h *GRPCFileHandler) handleMkdirOp( GID: p.GetOwnerGid(), } - mode := os.FileMode(p.GetMode()) + mode := permMode(p.GetMode()) if mode == 0 { mode = 0755 } @@ -552,6 +586,111 @@ func (h *GRPCFileHandler) handleTouchOp( return fileOpOkResp(rid) } +func (h *GRPCFileHandler) handleHashOp( + ctx context.Context, root *os.Root, rid string, p *pb.HashParams, +) (*pb.FileOperationResponse, error) { + if p == nil { + return fileOpErrResp(rid, errors.New("hash_params required")) + } + if _, err := hasherForAlgorithm(p.GetAlgorithm()); err != nil { + return fileOpErrResp(rid, err) + } + if len(p.GetPaths()) > maxHashPaths { + return fileOpErrResp(rid, errors.Errorf( + "too many paths to hash: %d, limit is %d", len(p.GetPaths()), maxHashPaths, + )) + } + + hashes := make([]*pb.FileHash, 0, len(p.GetPaths())) + for _, pth := range p.GetPaths() { + if err := ctx.Err(); err != nil { + return fileOpErrResp(rid, errors.Wrap(err, "hash operation canceled")) + } + + hashes = append(hashes, hashFileInRoot(ctx, root, pth, p.GetAlgorithm())) + } + + return &pb.FileOperationResponse{ + RequestId: rid, + Success: true, + Result: &pb.FileOperationResponse_HashResult{ + HashResult: &pb.HashResult{ + Algorithm: p.GetAlgorithm(), + Hashes: hashes, + }, + }, + }, nil +} + +// hashFileInRoot hashes a single file inside root. Any failure is reported in +// the returned FileHash.Error; per-file failures must not fail the operation. +func hashFileInRoot( + ctx context.Context, root *os.Root, path string, algorithm pb.HashAlgorithm, +) *pb.FileHash { + fh := &pb.FileHash{Path: path} + + rel, err := fsutil.RootRel(path) + if err != nil { + fh.Error = err.Error() + return fh + } + + info, err := root.Lstat(rel) + if err != nil { + fh.Error = err.Error() + return fh + } + + if info.IsDir() { + fh.Error = "is a directory" + return fh + } + + if !info.Mode().IsRegular() { + fh.Error = "not a regular file" + return fh + } + + hasher, err := hasherForAlgorithm(algorithm) + if err != nil { + fh.Error = err.Error() + return fh + } + + f, err := root.Open(rel) + if err != nil { + fh.Error = err.Error() + return fh + } + defer f.Close() + + n, err := io.Copy(hasher, &ctxReader{ctx: ctx, r: f}) + if err != nil { + fh.Error = err.Error() + return fh + } + + fh.Hash = hex.EncodeToString(hasher.Sum(nil)) + fh.Size = uint64(n) + + return fh +} + +// ctxReader aborts a streaming read when ctx is done. Hashing a multi-gigabyte +// file otherwise runs to completion no matter what happens to the connection. +type ctxReader struct { + ctx context.Context + r io.Reader +} + +func (r *ctxReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, errors.Wrap(err, "read canceled") + } + + return r.r.Read(p) +} + func fileInfoToStat(path string, info os.FileInfo) *pb.FileStat { ft := pb.FileType_FILE_TYPE_REGULAR switch { diff --git a/internal/app/grpc/file_handler_hash_test.go b/internal/app/grpc/file_handler_hash_test.go new file mode 100644 index 0000000..8e2f78b --- /dev/null +++ b/internal/app/grpc/file_handler_hash_test.go @@ -0,0 +1,292 @@ +package grpc + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + pb "github.com/gameap/gameap/pkg/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func hashOpRequest(p *pb.HashParams) *pb.FileOperationRequest { + return &pb.FileOperationRequest{ + RequestId: "req-1", + Operation: pb.FileOperationType_FILE_OPERATION_TYPE_HASH, + Parameters: &pb.FileOperationRequest_HashParams{ + HashParams: p, + }, + } +} + +func TestHandleFileOperation_HashAlgorithms(t *testing.T) { + tests := []struct { + name string + algorithm pb.HashAlgorithm + content string + expected string + }{ + { + name: "md5", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_MD5, + content: "", + expected: "d41d8cd98f00b204e9800998ecf8427e", + }, + { + name: "sha1", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA1, + content: "", + expected: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + }, + { + name: "sha256", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + content: "", + expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + name: "sha512", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA512, + content: "", + expected: "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce" + + "47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", + }, + { + name: "crc32", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_CRC32, + content: "123456789", + expected: "cbf43926", + }, + { + name: "crc64", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_CRC64, + content: "123456789", + expected: "995dc9bbdf1939fa", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "file.bin"), []byte(tc.content), 0o644)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"file.bin"}, + Algorithm: tc.algorithm, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + assert.Equal(t, tc.algorithm, result.GetAlgorithm()) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "file.bin", fh.GetPath()) + assert.Empty(t, fh.GetError()) + assert.Equal(t, tc.expected, fh.GetHash()) + assert.Equal(t, fh.GetHash(), strings.ToLower(fh.GetHash()), "hash must be lowercase hex") + assert.Equal(t, uint64(len(tc.content)), fh.GetSize()) + }) + } +} + +func TestHandleFileOperation_HashDirectory(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(workDir, "subdir"), 0o755)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"subdir"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "subdir", fh.GetPath()) + assert.Equal(t, "is a directory", fh.GetError()) + assert.Empty(t, fh.GetHash()) + assert.Zero(t, fh.GetSize()) +} + +func TestHandleFileOperation_HashMissingFile(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"no/such/file.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "no/such/file.txt", fh.GetPath()) + assert.NotEmpty(t, fh.GetError()) + assert.Empty(t, fh.GetHash()) +} + +func TestHandleFileOperation_HashMultiplePaths(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "ok.txt"), []byte("data"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(workDir, "dir"), 0o755)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"ok.txt", "dir", "missing.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 3) + + sum := sha256.Sum256([]byte("data")) + + ok := result.GetHashes()[0] + assert.Equal(t, "ok.txt", ok.GetPath()) + assert.Empty(t, ok.GetError()) + assert.Equal(t, hex.EncodeToString(sum[:]), ok.GetHash()) + assert.Equal(t, uint64(4), ok.GetSize()) + + dir := result.GetHashes()[1] + assert.Equal(t, "dir", dir.GetPath()) + assert.Equal(t, "is a directory", dir.GetError()) + assert.Empty(t, dir.GetHash()) + + missing := result.GetHashes()[2] + assert.Equal(t, "missing.txt", missing.GetPath()) + assert.NotEmpty(t, missing.GetError()) + assert.Empty(t, missing.GetHash()) +} + +func TestHandleFileOperation_HashTooManyPaths(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + paths := make([]string, maxHashPaths+1) + for i := range paths { + paths[i] = "f.txt" + } + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: paths, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "too many paths") +} + +func TestHandleFileOperation_HashCanceled(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "f.txt"), []byte("data"), 0o644)) + h := NewGRPCFileHandler(workDir) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + resp, err := h.HandleFileOperation(ctx, hashOpRequest(&pb.HashParams{ + Paths: []string{"f.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "canceled") +} + +func TestHandleFileOperation_HashNilParams(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + resp, err := h.HandleFileOperation(context.Background(), &pb.FileOperationRequest{ + RequestId: "req-1", + Operation: pb.FileOperationType_FILE_OPERATION_TYPE_HASH, + }) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "hash_params") +} + +func TestHandleFileOperation_HashUnspecifiedAlgorithm(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "f.txt"), []byte("data"), 0o644)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"f.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_UNSPECIFIED, + })) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.NotEmpty(t, resp.Error) +} + +func TestHandleFileOperation_HashPathTraversal(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"../outside"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "../outside", fh.GetPath()) + assert.Contains(t, fh.GetError(), "outside work directory") + assert.Empty(t, fh.GetHash()) +} + +func TestHandleFileOperation_HashSymlink(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "target.txt"), []byte("data"), 0o644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(workDir, "link.txt"))) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"link.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "link.txt", fh.GetPath()) + assert.Equal(t, "not a regular file", fh.GetError()) + assert.Empty(t, fh.GetHash()) + assert.Zero(t, fh.GetSize()) +} diff --git a/internal/app/grpc/file_handler_test.go b/internal/app/grpc/file_handler_test.go index 8835757..5fc3242 100644 --- a/internal/app/grpc/file_handler_test.go +++ b/internal/app/grpc/file_handler_test.go @@ -117,3 +117,118 @@ func TestGRPCFileHandler_SymlinkEscapeBlocked(t *testing.T) { } }) } + +func TestHandleFileList(t *testing.T) { + t.Run("recursive_missing_directory_returns_error", func(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + resp, err := h.HandleFileList(context.Background(), "req-1", &pb.FileListRequest{ + Path: "does/not/exist", + Recursive: true, + }) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.NotEmpty(t, resp.Error) + require.Len(t, resp.Files, 0) + }) + + t.Run("flat_missing_directory_returns_error", func(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + resp, err := h.HandleFileList(context.Background(), "req-2", &pb.FileListRequest{ + Path: "does/not/exist", + Recursive: false, + }) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.NotEmpty(t, resp.Error) + require.Len(t, resp.Files, 0) + }) + + t.Run("recursive_empty_directory_returns_empty_success", func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(workDir, "empty"), 0o755)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileList(context.Background(), "req-3", &pb.FileListRequest{ + Path: "empty", + Recursive: true, + }) + + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Empty(t, resp.Error) + require.Len(t, resp.Files, 0) + }) + + t.Run("recursive_regular_file_returns_error", func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("data"), 0o644)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileList(context.Background(), "req-5", &pb.FileListRequest{ + Path: "file.txt", + Recursive: true, + }) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "not a directory") + require.Len(t, resp.Files, 0) + }) + + t.Run("recursive_unreadable_root_returns_error", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory permissions are not enforced the same way on Windows") + } + if os.Geteuid() == 0 { + t.Skip("root bypasses directory permissions") + } + + workDir := t.TempDir() + locked := filepath.Join(workDir, "locked") + require.NoError(t, os.MkdirAll(locked, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(locked, "file.txt"), []byte("data"), 0o644)) + require.NoError(t, os.Chmod(locked, 0o000)) + t.Cleanup(func() { + _ = os.Chmod(locked, 0o755) + }) + + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileList(context.Background(), "req-6", &pb.FileListRequest{ + Path: "locked", + Recursive: true, + }) + + require.NoError(t, err) + assert.False(t, resp.Success, "a root directory that cannot be read must not answer with an empty success") + assert.NotEmpty(t, resp.Error) + require.Len(t, resp.Files, 0) + }) + + t.Run("recursive_existing_directory_lists_entries", func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(workDir, "sub"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "sub", "file.txt"), []byte("data"), 0o644)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileList(context.Background(), "req-4", &pb.FileListRequest{ + Path: "", + Recursive: true, + }) + + require.NoError(t, err) + assert.True(t, resp.Success) + require.Len(t, resp.Files, 2) + + paths := make([]string, 0, len(resp.Files)) + for _, f := range resp.Files { + paths = append(paths, f.Path) + } + assert.Contains(t, paths, "sub") + assert.Contains(t, paths, filepath.Join("sub", "file.txt")) + }) +} diff --git a/internal/app/grpc/hash.go b/internal/app/grpc/hash.go new file mode 100644 index 0000000..1bad27d --- /dev/null +++ b/internal/app/grpc/hash.go @@ -0,0 +1,33 @@ +package grpc + +import ( + "crypto/md5" //nolint:gosec // md5 hashing is part of the protocol contract + "crypto/sha1" //nolint:gosec // sha1 hashing is part of the protocol contract + "crypto/sha256" + "crypto/sha512" + "hash" + "hash/crc32" + "hash/crc64" + + pb "github.com/gameap/gameap/pkg/proto" + "github.com/pkg/errors" +) + +func hasherForAlgorithm(a pb.HashAlgorithm) (hash.Hash, error) { + switch a { + case pb.HashAlgorithm_HASH_ALGORITHM_MD5: + return md5.New(), nil //nolint:gosec // md5 hashing is part of the protocol contract + case pb.HashAlgorithm_HASH_ALGORITHM_SHA1: + return sha1.New(), nil //nolint:gosec // sha1 hashing is part of the protocol contract + case pb.HashAlgorithm_HASH_ALGORITHM_SHA256: + return sha256.New(), nil + case pb.HashAlgorithm_HASH_ALGORITHM_SHA512: + return sha512.New(), nil + case pb.HashAlgorithm_HASH_ALGORITHM_CRC32: + return crc32.NewIEEE(), nil + case pb.HashAlgorithm_HASH_ALGORITHM_CRC64: + return crc64.New(crc64.MakeTable(crc64.ECMA)), nil + default: + return nil, errors.Errorf("unsupported hash algorithm: %s", a) + } +} diff --git a/internal/app/grpc/heartbeat.go b/internal/app/grpc/heartbeat.go index 6dc586b..7c7c384 100644 --- a/internal/app/grpc/heartbeat.go +++ b/internal/app/grpc/heartbeat.go @@ -4,9 +4,9 @@ import ( "runtime" pb "github.com/gameap/gameap/pkg/proto" - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/load" - "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" log "github.com/sirupsen/logrus" ) diff --git a/internal/app/grpc/server_handler.go b/internal/app/grpc/server_handler.go index e557546..293a6b5 100644 --- a/internal/app/grpc/server_handler.go +++ b/internal/app/grpc/server_handler.go @@ -99,7 +99,7 @@ func (h *GRPCServerHandler) handleServerProto(srv *pb.Server, settings domain.Se settings, updatedAt, int(srv.GetCpuLimit()), - int64(srv.GetRamLimit()), + srv.GetRamLimit(), ) h.serverRepo.SaveToCache(existing) @@ -131,7 +131,7 @@ func (h *GRPCServerHandler) handleServerProto(srv *pb.Server, settings domain.Se settings, updatedAt, int(srv.GetCpuLimit()), - int64(srv.GetRamLimit()), + srv.GetRamLimit(), ) h.serverRepo.SaveToCache(server) diff --git a/internal/app/grpc/transfer_handler.go b/internal/app/grpc/transfer_handler.go index a2a72bb..f3c721b 100644 --- a/internal/app/grpc/transfer_handler.go +++ b/internal/app/grpc/transfer_handler.go @@ -100,9 +100,12 @@ func (h *GRPCTransferHandler) HandleFileUploadTask(ctx context.Context, requestI } } - // Duplicate check: if transfer is already active, skip. + // Duplicate check: if transfer is already active, reject this request so the + // API fails fast instead of waiting for a dispatch timeout; the original + // transfer keeps running and answers its own request. if _, loaded := h.activeTransfers.LoadOrStore(task.TransferId, struct{}{}); loaded { - l.Warn("Transfer already active, skipping duplicate") + l.Warn("Transfer already active, rejecting duplicate") + h.sendResponse(requestID, false, "transfer already active: "+task.TransferId) return } defer h.activeTransfers.Delete(task.TransferId) diff --git a/internal/app/metrics/node_collector.go b/internal/app/metrics/node_collector.go index 2d66715..93fd942 100644 --- a/internal/app/metrics/node_collector.go +++ b/internal/app/metrics/node_collector.go @@ -9,12 +9,12 @@ import ( "github.com/gameap/daemon/internal/app/config" "github.com/gameap/daemon/internal/app/domain" "github.com/pkg/errors" - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/disk" - "github.com/shirou/gopsutil/v3/host" - "github.com/shirou/gopsutil/v3/load" - "github.com/shirou/gopsutil/v3/mem" - "github.com/shirou/gopsutil/v3/net" + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/host" + "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" + "github.com/shirou/gopsutil/v4/net" log "github.com/sirupsen/logrus" ) diff --git a/internal/app/repositories/errors.go b/internal/app/repositories/errors.go deleted file mode 100644 index e27233c..0000000 --- a/internal/app/repositories/errors.go +++ /dev/null @@ -1,7 +0,0 @@ -package repositories - -import ( - "github.com/pkg/errors" -) - -var errInvalidServerID = errors.New("server not found, invalid id") diff --git a/internal/app/repositories/gdtask_repository.go b/internal/app/repositories/gdtask_repository.go deleted file mode 100644 index 036f075..0000000 --- a/internal/app/repositories/gdtask_repository.go +++ /dev/null @@ -1,207 +0,0 @@ -package repositories - -import ( - "context" - "encoding/json" - "net/http" - "strconv" - - "github.com/gameap/daemon/internal/app/contracts" - "github.com/gameap/daemon/internal/app/domain" - "github.com/gameap/daemon/pkg/logger" - "github.com/pkg/errors" - log "github.com/sirupsen/logrus" -) - -type GDTaskRepository struct { - client contracts.APIRequestMaker - serverRepository domain.ServerRepository -} - -type task struct { - Task string `json:"task"` - Cmd string `json:"cmd"` - Status string `json:"status"` - ID int `json:"id"` - RunAfterID int `json:"run_after_id"` - Server int `json:"server_id"` -} - -func NewGDTaskRepository( - client contracts.APIRequestMaker, - serverRepository domain.ServerRepository, -) *GDTaskRepository { - return &GDTaskRepository{ - client: client, - serverRepository: serverRepository, - } -} - -func (repository *GDTaskRepository) FindByStatus( - ctx context.Context, - status domain.GDTaskStatus, -) ([]*domain.GDTask, error) { - resp, err := repository.client.Request(ctx, domain.APIRequest{ - Method: http.MethodGet, - URL: "/gdaemon_api/tasks", - QueryParams: map[string]string{ - "filter[status]": string(status), - "append": "status_num", - }, - }) - if err != nil { - return nil, errors.WithMessage(err, "[repositories.GDTaskRepository] failed to find gameap daemon tasks") - } - - if resp.StatusCode() != http.StatusOK { - return nil, errors.WithMessage( - domain.NewErrInvalidResponseFromAPI(resp.StatusCode(), resp.Body()), - "[repositories.GDTaskRepository] failed to find gameap daemon tasks", - ) - } - - var items []task - err = json.Unmarshal(resp.Body(), &items) - if err != nil { - return nil, errors.WithMessage(err, "[repositories.GDTaskRepository] failed to unmarshal gameap daemon tasks") - } - - tasks := make([]*domain.GDTask, 0, len(items)) - for i := range items { - var server *domain.Server - - if items[i].Server > 0 { - server, err = repository.serverRepository.FindByID(ctx, items[i].Server) - if err != nil { - return nil, errors.WithMessage(err, "[repositories.GDTaskRepository] failed to join server to gameap daemon task") - } - - if server == nil { - logger.WithFields(ctx, log.Fields{ - "gameServerID": items[i].Server, - "gdTaskID": items[i].ID, - }).Warn("invalid task, game server not found") - - continue - } - } - - gdTask := domain.NewGDTask( - items[i].ID, - items[i].RunAfterID, - server, - domain.GDTaskCommand(items[i].Task), - items[i].Cmd, - domain.GDTaskStatus(items[i].Status), - ) - - tasks = append(tasks, gdTask) - } - - return tasks, nil -} - -func (repository *GDTaskRepository) FindByID(ctx context.Context, id int) (*domain.GDTask, error) { - resp, err := repository.client.Request(ctx, domain.APIRequest{ - Method: http.MethodGet, - URL: "/gdaemon_api/tasks/{id}", - PathParams: map[string]string{ - "id": strconv.Itoa(id), - }, - }) - - if err != nil { - return nil, err - } - - var tsk task - - err = json.Unmarshal(resp.Body(), &tsk) - if err != nil { - return nil, err - } - - var server *domain.Server - if tsk.Server > 0 { - server, err = repository.serverRepository.FindByID(ctx, tsk.Server) - if err != nil { - return nil, err - } - } - - return domain.NewGDTask( - tsk.ID, - tsk.RunAfterID, - server, - domain.GDTaskCommand(tsk.Task), - tsk.Cmd, - domain.GDTaskStatus(tsk.Status), - ), nil -} - -func (repository *GDTaskRepository) Save(ctx context.Context, gdtask *domain.GDTask) error { - marshalled, err := json.Marshal(struct { - Status uint8 `json:"status"` - }{gdtask.StatusNum()}) - if err != nil { - return errors.WithMessage(err, "[repositories.GDTaskRepository] failed to marshal gd task") - } - - resp, err := repository.client.Request(ctx, domain.APIRequest{ - Method: http.MethodPut, - URL: "/gdaemon_api/tasks/{id}", - Body: marshalled, - PathParams: map[string]string{ - "id": strconv.Itoa(gdtask.ID()), - }, - }) - if err != nil { - return errors.WithMessage(err, "failed to save gameap daemon task") - } - - if resp.StatusCode() != http.StatusOK { - return errors.WithMessage( - domain.NewErrInvalidResponseFromAPI(resp.StatusCode(), resp.Body()), - "[repositories.GDTaskRepository] failed to save gameap daemon task", - ) - } - - if gdtask.Server() != nil { - err = repository.serverRepository.Save(ctx, gdtask.Server()) - if err != nil { - return errors.WithMessage(err, "failed to save game server") - } - } - - return nil -} - -func (repository *GDTaskRepository) AppendOutput(ctx context.Context, gdtask *domain.GDTask, output []byte) error { - marshalled, err := json.Marshal(struct { - Output string `json:"output"` - }{string(output)}) - if err != nil { - return errors.WithMessage(err, "[repositories.GDTaskRepository] failed to marshal output") - } - - resp, err := repository.client.Request(ctx, domain.APIRequest{ - Method: http.MethodPut, - URL: "/gdaemon_api/tasks/{id}/output", - Body: marshalled, - PathParams: map[string]string{ - "id": strconv.Itoa(gdtask.ID()), - }, - }) - if err != nil { - return errors.WithMessage(err, "[repositories.GDTaskRepository] failed to append output of gameap daemon task") - } - - if resp.StatusCode() != http.StatusOK { - return errors.WithMessage( - domain.NewErrInvalidResponseFromAPI(resp.StatusCode(), resp.Body()), - "[repositories.GDTaskRepository] failed to save gameap daemon task", - ) - } - - return nil -} diff --git a/internal/app/repositories/server_repository.go b/internal/app/repositories/server_repository.go index b719958..4d366a1 100644 --- a/internal/app/repositories/server_repository.go +++ b/internal/app/repositories/server_repository.go @@ -2,92 +2,19 @@ package repositories import ( "context" - "encoding/json" - "net/http" - "strconv" "sync" "time" - "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/internal/app/domain" - "github.com/gameap/daemon/pkg/limiter" - "github.com/pkg/errors" - "github.com/samber/lo" - log "github.com/sirupsen/logrus" -) - -const serverCacheTTL = 10 * time.Second - -// limit scheduler consts. -const ( - schedulerDefaultDuration = 1 * time.Second - schedulerDefaultBulkCallFromNum = 5 - schedulerDefaultBulkSize = 100 ) type ServerRepository struct { - limitScheduler *limiter.CallScheduler - innerRepo apiServerRepo - servers sync.Map - lastUpdated sync.Map - mu sync.Mutex - grpcMode bool + servers sync.Map + lastUpdated sync.Map } -func NewServerRepository(ctx context.Context, client contracts.APIRequestMaker, logger *log.Logger) *ServerRepository { - serverRepo := &ServerRepository{ - innerRepo: apiServerRepo{ - client: client, - }, - } - - limitScheduler := limiter.NewAPICallScheduler( - schedulerDefaultDuration, - schedulerDefaultBulkCallFromNum, - func(ctx context.Context, q *limiter.Queue) error { - server, ok := q.Get().(*domain.Server) - if !ok { - return errors.New("failed to get server from queue") - } - - err := serverRepo.innerRepo.Save(ctx, server) - if err != nil { - return errors.WithMessage(err, "failed to save server") - } - - return nil - }, - func(ctx context.Context, q *limiter.Queue) error { - s := q.GetN(schedulerDefaultBulkSize) - servers := make([]*domain.Server, 0, len(s)) - for i := range s { - server, ok := s[i].(*domain.Server) - if !ok { - return errors.New("failed to get server from queue") - } - - servers = append(servers, server) - } - - err := serverRepo.innerRepo.SaveBulk(ctx, servers) - if err != nil { - return errors.WithMessage(err, "failed to save servers") - } - - return nil - }, - logger, - ) - - go limitScheduler.Run(ctx) - - serverRepo.limitScheduler = limitScheduler - - return serverRepo -} - -func (repo *ServerRepository) SetGRPCMode(enabled bool) { - repo.grpcMode = enabled +func NewServerRepository() *ServerRepository { + return &ServerRepository{} } func (repo *ServerRepository) IDsFromCache() []int { @@ -101,70 +28,20 @@ func (repo *ServerRepository) IDsFromCache() []int { return ids } -func (repo *ServerRepository) IDs(ctx context.Context) ([]int, error) { - if repo.grpcMode { - return repo.IDsFromCache(), nil - } - return repo.innerRepo.IDs(ctx) +func (repo *ServerRepository) IDs(_ context.Context) ([]int, error) { + return repo.IDsFromCache(), nil } -func (repo *ServerRepository) FindByID(ctx context.Context, id int) (*domain.Server, error) { - repo.mu.Lock() - defer repo.mu.Unlock() - - if repo.grpcMode { - server, ok := repo.FindByIDFromCache(id) - if !ok { - return nil, nil - } - return server, nil - } - - var err error - var server *domain.Server - - loadedServer, ok := repo.servers.Load(id) - //nolint:nestif +func (repo *ServerRepository) FindByID(_ context.Context, id int) (*domain.Server, error) { + server, ok := repo.FindByIDFromCache(id) if !ok { - server, err = repo.innerRepo.FindByID(ctx, id) - if err != nil { - return nil, err - } - if server != nil { - repo.lastUpdated.Store(id, time.Now()) - } - } else { - server = loadedServer.(*domain.Server) - - lastUpdated, ok := repo.lastUpdated.Load(id) - if ok && time.Until(lastUpdated.(time.Time))+serverCacheTTL < 0 && !server.IsModified() { - server, err = repo.innerRepo.FindByID(ctx, id) - if err != nil { - return nil, err - } - if server != nil { - repo.lastUpdated.Store(id, time.Now()) - } - } - } - - if server != nil { - repo.servers.Store(id, server) + return nil, nil } return server, nil } -func (repo *ServerRepository) Save(_ context.Context, server *domain.Server) error { - if repo.grpcMode { - return nil - } - - repo.mu.Lock() - defer repo.mu.Unlock() - - repo.limitScheduler.Put(server) - +func (repo *ServerRepository) Save(_ context.Context, _ *domain.Server) error { return nil } @@ -192,345 +69,3 @@ func (repo *ServerRepository) CountOnlineServers() int { }) return count } - -//nolint:maligned -type serverStruct struct { - Vars map[string]string `json:"vars"` - ForceStopCommand string `json:"force_stop_command"` - Dir string `json:"dir"` - LastProcessCheck string `json:"last_process_check"` - Name string `json:"name"` - UUID string `json:"uuid"` - UUIDShort string `json:"uuid_short"` - RestartCommand string `json:"restart_command"` - StopCommand string `json:"stop_command"` - IP string `json:"server_ip"` - StartCommand string `json:"start_command"` - UpdatedAt string `json:"updated_at"` - RconPassword string `json:"rcon"` - User string `json:"su_user"` - Game domain.Game `json:"game"` - Settings []map[string]interface{} `json:"settings"` - GameMod domain.GameMod `json:"game_mod"` - RAMLimit *int64 `json:"ram_limit"` - ConnectPort int `json:"server_port"` - ID int `json:"id"` - InstallStatus int `json:"installed"` - RconPort int `json:"rcon_port"` - QueryPort int `json:"query_port"` - CPULimit *int `json:"cpu_limit"` - Enabled bool `json:"enabled"` - ProcessActive bool `json:"process_active"` - Blocked bool `json:"blocked"` -} - -type apiServerRepo struct { - client contracts.APIRequestMaker - - servers sync.Map // [int]*domain.Server (serverID => server) -} - -func (apiRepo *apiServerRepo) IDs(ctx context.Context) ([]int, error) { - response, err := apiRepo.client.Request(ctx, domain.APIRequest{ - Method: http.MethodGet, - URL: "/gdaemon_api/servers", - }) - - if err != nil { - return nil, err - } - - if response.StatusCode() != http.StatusOK { - return nil, domain.NewErrInvalidResponseFromAPI(response.StatusCode(), response.Body()) - } - - var srvList []struct { - ID int `json:"id"` - } - err = json.Unmarshal(response.Body(), &srvList) - if err != nil { - return nil, err - } - - ids := make([]int, 0, len(srvList)) - - for _, v := range srvList { - ids = append(ids, v.ID) - } - - return ids, nil -} - -//nolint:funlen -func (apiRepo *apiServerRepo) FindByID(ctx context.Context, id int) (*domain.Server, error) { - response, err := apiRepo.client.Request(ctx, domain.APIRequest{ - Method: http.MethodGet, - URL: "/gdaemon_api/servers/{id}", - PathParams: map[string]string{ - "id": strconv.Itoa(id), - }, - }) - - if err != nil { - return nil, errors.WithMessage(err, "[repositories.apiServerRepo] failed to fetch server") - } - - if response.StatusCode() == http.StatusNotFound { - return nil, nil - } - if response.StatusCode() != http.StatusOK { - return nil, errors.WithMessage( - domain.NewErrInvalidResponseFromAPI(response.StatusCode(), response.Body()), - "[repositories.apiServerRepo] failed find game server", - ) - } - - var srv serverStruct - err = json.Unmarshal(response.Body(), &srv) - if err != nil { - return nil, err - } - - var lastProcessCheck time.Time - if srv.LastProcessCheck != "" { - lastProcessCheck, err = time.Parse("2006-01-02 15:04:05", srv.LastProcessCheck) - if err != nil { - lastProcessCheck, err = time.Parse(time.RFC3339, srv.LastProcessCheck) - if err != nil { - return nil, errors.WithMessage( - err, - "[repositories.apiServerRepo] failed to parse last process check time", - ) - } - } - } - - var updatedAt time.Time - if srv.UpdatedAt != "" { - updatedAt, err = time.Parse(time.RFC3339, srv.UpdatedAt) - if err != nil { - return nil, errors.WithMessage( - err, - "[repositories.apiServerRepo] failed to parse updated at time", - ) - } - } - - settings := domain.Settings{} - - for _, v := range srv.Settings { - sname, ok := v["name"] - if !ok { - continue - } - - snameString, ok := sname.(string) - if !ok { - continue - } - - svalue, ok := v["value"] - if !ok { - continue - } - - svalueString, ok := svalue.(string) - if !ok { - continue - } - - settings[snameString] = svalueString - } - - var server *domain.Server - if item, exists := apiRepo.servers.Load(srv.ID); exists { - server = item.(*domain.Server) - - installationStatus := server.InstallationStatus() - if !server.IsValueModified("installationStatus") && - server.InstallationStatus() != domain.InstallationStatus(srv.InstallStatus) { - installationStatus = domain.InstallationStatus(srv.InstallStatus) - } - - processActive := server.IsActive() - lastStatusCheck := server.LastStatusCheck() - if !server.IsValueModified("status") && server.IsActive() != srv.ProcessActive { - processActive = srv.ProcessActive - lastStatusCheck = lastProcessCheck - } - - cpuLimit := 0 - if srv.CPULimit != nil { - cpuLimit = *srv.CPULimit - } - var ramLimit int64 - if srv.RAMLimit != nil { - ramLimit = *srv.RAMLimit - } - - server.Set( - srv.Enabled, - installationStatus, - srv.Blocked, - srv.Name, - srv.UUID, - srv.UUIDShort, - srv.Game, - srv.GameMod, - srv.IP, - srv.ConnectPort, - srv.QueryPort, - srv.RconPort, - srv.RconPassword, - srv.Dir, - srv.User, - srv.StartCommand, - srv.StopCommand, - srv.ForceStopCommand, - srv.RestartCommand, - processActive, - lastStatusCheck, - srv.Vars, - settings, - updatedAt, - cpuLimit, - ramLimit, - ) - - return server, nil - } - - cpuLimit := 0 - if srv.CPULimit != nil { - cpuLimit = *srv.CPULimit - } - var ramLimit int64 - if srv.RAMLimit != nil { - ramLimit = *srv.RAMLimit - } - - server = domain.NewServer( - srv.ID, - srv.Enabled, - domain.InstallationStatus(srv.InstallStatus), - srv.Blocked, - srv.Name, - srv.UUID, - srv.UUIDShort, - srv.Game, - srv.GameMod, - srv.IP, - srv.ConnectPort, - srv.QueryPort, - srv.RconPort, - srv.RconPassword, - srv.Dir, - srv.User, - srv.StartCommand, - srv.StopCommand, - srv.ForceStopCommand, - srv.RestartCommand, - srv.ProcessActive, - lastProcessCheck, - srv.Vars, - settings, - updatedAt, - cpuLimit, - ramLimit, - ) - - apiRepo.servers.Store(srv.ID, server) - - return server, nil -} - -type serverSaveStruct struct { - InstallationStatus *int `json:"installed,omitempty"` - LastProcessCheck *string `json:"last_process_check,omitempty"` - ID int `json:"id"` - ProcessActive uint8 `json:"process_active"` -} - -func saveStructFromServer(server *domain.Server) serverSaveStruct { - saveStruct := serverSaveStruct{ - ID: server.ID(), - ProcessActive: 0, - } - - if server.IsValueModified("installationStatus") { - saveStruct.InstallationStatus = lo.ToPtr(int(server.InstallationStatus())) - } - - if server.IsActive() && server.IsValueModified("status") { - saveStruct.ProcessActive = 1 - } - - if !server.LastStatusCheck().IsZero() && server.IsValueModified("status") { - saveStruct.LastProcessCheck = lo.ToPtr(server.LastStatusCheck().UTC().Format("2006-01-02 15:04:05")) - } - - return saveStruct -} - -func (apiRepo *apiServerRepo) Save(ctx context.Context, server *domain.Server) error { - serverSaveValues := saveStructFromServer(server) - - server.UnmarkModifiedFlag() - - marshalled, err := json.Marshal(serverSaveValues) - if err != nil { - return errors.WithMessage(err, "[repositories.apiServerRepo] failed to marshal server") - } - - resp, err := apiRepo.client.Request(ctx, domain.APIRequest{ - Method: http.MethodPut, - URL: "/gdaemon_api/servers/{id}", - Body: marshalled, - PathParams: map[string]string{ - "id": strconv.Itoa(server.ID()), - }, - }) - if err != nil { - return errors.WithMessage(err, "[repositories.apiServerRepo] failed to saving server") - } - - if resp.StatusCode() != http.StatusOK { - return errors.WithMessage( - domain.NewErrInvalidResponseFromAPI(resp.StatusCode(), resp.Body()), - "[repositories.apiServerRepo] failed to saving server", - ) - } - - return nil -} - -func (apiRepo *apiServerRepo) SaveBulk(ctx context.Context, servers []*domain.Server) error { - serverSaveValues := make([]serverSaveStruct, 0, len(servers)) - for i := range servers { - serverSaveValues = append(serverSaveValues, saveStructFromServer(servers[i])) - servers[i].UnmarkModifiedFlag() - } - - marshalled, err := json.Marshal(serverSaveValues) - if err != nil { - return errors.WithMessage(err, "[repositories.apiServerRepo] failed to marshal servers") - } - - resp, err := apiRepo.client.Request(ctx, domain.APIRequest{ - Method: http.MethodPatch, - URL: "/gdaemon_api/servers", - Body: marshalled, - }) - if err != nil { - return errors.WithMessage(err, "[repositories.apiServerRepo] failed to bulk saving servers") - } - - if resp.StatusCode() != http.StatusOK { - return errors.WithMessage( - domain.NewErrInvalidResponseFromAPI(resp.StatusCode(), resp.Body()), - "[repositories.apiServerRepo] failed to bulk saving servers", - ) - } - - return nil -} diff --git a/internal/app/repositories/server_task_repository.go b/internal/app/repositories/server_task_repository.go deleted file mode 100644 index ef54e61..0000000 --- a/internal/app/repositories/server_task_repository.go +++ /dev/null @@ -1,152 +0,0 @@ -package repositories - -import ( - "context" - "encoding/json" - "net/http" - "strconv" - "time" - - "github.com/gameap/daemon/internal/app/contracts" - "github.com/gameap/daemon/internal/app/domain" - "github.com/pkg/errors" -) - -type ServerTaskRepository struct { - client contracts.APIRequestMaker - serverRepository domain.ServerRepository -} - -func NewServerTaskRepository( - client contracts.APIRequestMaker, - serverRepository domain.ServerRepository, -) *ServerTaskRepository { - return &ServerTaskRepository{ - client: client, - serverRepository: serverRepository, - } -} - -type serverTask struct { - Command string `json:"command"` - ExecuteDate string `json:"execute_date"` - ID int `json:"id"` - ServerID int `json:"server_id"` - Repeat int `json:"repeat"` - RepeatPeriod int `json:"repeat_period"` - Counter int `json:"counter"` -} - -func (repo *ServerTaskRepository) Find(ctx context.Context) ([]*domain.ServerTask, error) { - resp, err := repo.client.Request(ctx, domain.APIRequest{ - Method: http.MethodGet, - URL: "/gdaemon_api/servers_tasks", - }) - - if err != nil { - return nil, errors.WithMessage(err, "[repositories.ServerTaskRepository] failed to find game server tasks") - } - - if resp.StatusCode() != http.StatusOK { - return nil, errors.WithMessage( - domain.NewErrInvalidResponseFromAPI(resp.StatusCode(), resp.Body()), - "[repositories.ServerTaskRepository] failed to find game servers tasks", - ) - } - - var items []serverTask - err = json.Unmarshal(resp.Body(), &items) - if err != nil { - return nil, errors.WithMessage(err, "[repositories.ServerTaskRepository] failed to unmarshal server tasks") - } - - tasks := make([]*domain.ServerTask, 0, len(items)) - for i := range items { - server, err := repo.serverRepository.FindByID(ctx, items[i].ServerID) - if err != nil { - return nil, errors.WithMessage(err, "[repositories.ServerTaskRepository] failed to join server to server task") - } - if server == nil { - return nil, errInvalidServerID - } - - executeDate, err := time.Parse("2006-01-02 15:04:05", items[i].ExecuteDate) - if err != nil { - return nil, errors.WithMessage(err, "[repositories.ServerTaskRepository] failed to parse server task execute date") - } - - task := domain.NewServerTask( - items[i].ID, - domain.ServerTaskCommand(items[i].Command), - server, - items[i].Repeat, - time.Duration(items[i].RepeatPeriod)*time.Second, - items[i].Counter, - executeDate, - ) - - tasks = append(tasks, task) - } - - return tasks, nil -} - -func (repo *ServerTaskRepository) Save(ctx context.Context, task *domain.ServerTask) error { - marshalled, err := json.Marshal(task) - if err != nil { - return errors.WithMessage(err, "failed to marshal server task") - } - - resp, err := repo.client.Request(ctx, domain.APIRequest{ - Method: http.MethodPut, - URL: "/gdaemon_api/servers_tasks/{id}", - Body: marshalled, - PathParams: map[string]string{ - "id": strconv.Itoa(task.ID()), - }, - }) - if err != nil { - return errors.WithMessage(err, "[repositories.ServerTaskRepository] failed to save server task") - } - - if resp.StatusCode() != http.StatusOK { - return errors.WithMessage( - domain.NewErrInvalidResponseFromAPI(resp.StatusCode(), resp.Body()), - "[repositories.ServerTaskRepository] failed to save server task", - ) - } - - return nil -} - -func (repo *ServerTaskRepository) Fail(ctx context.Context, task *domain.ServerTask, output []byte) error { - marshalled, err := json.Marshal(struct { - Output string `json:"output"` - }{ - Output: string(output), - }) - if err != nil { - return errors.WithMessage(err, "[repositories.ServerTaskRepository] failed to marshal server task output") - } - - resp, err := repo.client.Request(ctx, domain.APIRequest{ - Method: http.MethodPost, - URL: "/gdaemon_api/servers_tasks/{id}/fail", - Body: marshalled, - PathParams: map[string]string{ - "id": strconv.Itoa(task.ID()), - }, - }) - if err != nil { - return errors.WithMessage(err, "[repositories.ServerTaskRepository] failed to save server task fail info") - } - - if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated { - return errors.WithMessage( - domain.NewErrInvalidResponseFromAPI(resp.StatusCode(), resp.Body()), - "[repositories.ServerTaskRepository] failed to save server task fail info", - ) - } - - return nil -} diff --git a/internal/app/run.go b/internal/app/run.go index 8457314..86546c7 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -138,51 +138,39 @@ func initialize(c *cli.Context) error { group, ctx := errgroup.WithContext(ctx) - if cfg.GRPC.Enabled { - log.Info("Starting gRPC Client...") + log.Info("Starting gRPC Client...") - connectionManager, err := container.ConnectionManager(ctx) - if err != nil { - return err - } - - statusReporter, err := container.ServerStatusReporter(ctx) - if err != nil { - return err - } + connectionManager, err := container.ConnectionManager(ctx) + if err != nil { + return err + } - processRunner.SetGRPCComponents(connectionManager, statusReporter) - processRunner.EnableGRPCMode() - - group.Go(processRunner.RunGRPCClient(ctx, cfg)) - group.Go(processRunner.RunGDaemonTaskScheduler(ctx, cfg)) - group.Go(processRunner.RunServersLoopWithReporter(ctx, cfg)) - group.Go(processRunner.RunServerScheduler(ctx, cfg)) - - if cfg.Metrics.IsEnabled() { - metricsService, err := container.MetricsService(ctx) - if err != nil { - return err - } - group.Go(func() error { return metricsService.Run(ctx) }) - log.WithFields(log.Fields{ - "interval": cfg.Metrics.CollectionInterval, - "retention": cfg.Metrics.RetentionDuration, - }).Info("Starting metrics collector") - } + statusReporter, err := container.ServerStatusReporter(ctx) + if err != nil { + return err + } - log.Info("Running in gRPC mode") - } else { - log.Info("Starting GDaemon Server...") + processRunner.SetGRPCComponents(connectionManager, statusReporter) - group.Go(processRunner.RunGDaemonServer(ctx, cfg)) - group.Go(processRunner.RunGDaemonTaskScheduler(ctx, cfg)) - group.Go(processRunner.RunServersLoop(ctx, cfg)) - group.Go(processRunner.RunServerScheduler(ctx, cfg)) + group.Go(processRunner.RunGRPCClient(ctx, cfg)) + group.Go(processRunner.RunGDaemonTaskScheduler(ctx, cfg)) + group.Go(processRunner.RunServersLoop(ctx, cfg)) + group.Go(processRunner.RunServerScheduler(ctx, cfg)) - log.Info("Running in legacy mode") + if cfg.Metrics.IsEnabled() { + metricsService, err := container.MetricsService(ctx) + if err != nil { + return err + } + group.Go(func() error { return metricsService.Run(ctx) }) + log.WithFields(log.Fields{ + "interval": cfg.Metrics.CollectionInterval, + "retention": cfg.Metrics.RetentionDuration, + }).Info("Starting metrics collector") } + log.Info("Running in gRPC mode") + err = group.Wait() if err != nil { return err diff --git a/internal/app/server/commands/commands.go b/internal/app/server/commands/commands.go deleted file mode 100644 index 05e5fab..0000000 --- a/internal/app/server/commands/commands.go +++ /dev/null @@ -1,68 +0,0 @@ -package commands - -import ( - "context" - "io" - - "github.com/et-nik/binngo/decode" - "github.com/gameap/daemon/internal/app/contracts" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/gameap/daemon/pkg/logger" - "github.com/pkg/errors" -) - -type Commands struct { - executor contracts.Executor -} - -func NewCommands(executor contracts.Executor) *Commands { - return &Commands{ - executor: executor, - } -} - -func (c *Commands) Handle(ctx context.Context, readWriter io.ReadWriter) error { - var msg commandExec - decoder := decode.NewDecoder(readWriter) - err := decoder.Decode(&msg) - if errors.Is(err, io.EOF) { - return io.EOF - } - if err != nil { - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusError, - Info: "Failed to decode message", - }) - } - - return c.executeCommand(ctx, msg, readWriter) -} - -func (c Commands) executeCommand(ctx context.Context, msg commandExec, writer io.Writer) error { - logger.WithField(ctx, "command", msg.Command).Debug("Executing command") - - out, exitCode, err := c.executor.Exec(ctx, msg.Command, contracts.ExecutorOptions{ - WorkDir: msg.WorkDir, - }) - - if err != nil { - logger.WithField(ctx, "error", err).Warn("Executing failed") - - return response.WriteResponse(writer, response.Response{ - Code: response.StatusError, - Info: err.Error(), - }) - } - - logger.Logger(ctx). - WithField("command", msg.Command). - WithField("exitCode", exitCode). - WithField("outSize", len(out)). - Debug("Command executed") - - return response.WriteResponse(writer, Response{ - Code: response.StatusOK, - ExitCode: exitCode, - Output: string(out), - }) -} diff --git a/internal/app/server/commands/requests.go b/internal/app/server/commands/requests.go deleted file mode 100644 index 51bdc8f..0000000 --- a/internal/app/server/commands/requests.go +++ /dev/null @@ -1,48 +0,0 @@ -package commands - -import ( - "errors" - - "github.com/et-nik/binngo/decode" -) - -var errInvalidCommandExecMessage = errors.New("unknown binn value, cannot be presented as execute command message") - -type commandExec struct { - Command string - WorkDir string - Kind uint8 -} - -func (s *commandExec) UnmarshalBINN(bytes []byte) error { - var v []interface{} - - err := decode.Unmarshal(bytes, &v) - if err != nil { - return err - } - if len(v) < 3 { - return errInvalidCommandExecMessage - } - - kind, ok := v[0].(uint8) - if !ok { - return errInvalidCommandExecMessage - } - - command, ok := v[1].(string) - if !ok { - return errInvalidCommandExecMessage - } - - workDir, ok := v[2].(string) - if !ok { - return errInvalidCommandExecMessage - } - - s.Kind = kind - s.Command = command - s.WorkDir = workDir - - return nil -} diff --git a/internal/app/server/commands/response.go b/internal/app/server/commands/response.go deleted file mode 100644 index 9daeb06..0000000 --- a/internal/app/server/commands/response.go +++ /dev/null @@ -1,17 +0,0 @@ -package commands - -import ( - "github.com/et-nik/binngo" - "github.com/gameap/daemon/internal/app/server/response" -) - -type Response struct { - Output string - ExitCode int - Code response.Code -} - -func (r Response) MarshalBINN() ([]byte, error) { - resp := []interface{}{r.Code, r.ExitCode, r.Output} - return binngo.Marshal(&resp) -} diff --git a/internal/app/server/enum.go b/internal/app/server/enum.go deleted file mode 100644 index 9bb9c2a..0000000 --- a/internal/app/server/enum.go +++ /dev/null @@ -1,13 +0,0 @@ -package server - -type Mode int - -const ( - ModeNoAuth Mode = iota - ModeAuth - ModeCommands - ModeFiles - ModeStatus - - ModeUnknown = -1 -) diff --git a/internal/app/server/files/enum.go b/internal/app/server/files/enum.go deleted file mode 100644 index d04ee8f..0000000 --- a/internal/app/server/files/enum.go +++ /dev/null @@ -1,36 +0,0 @@ -package files - -type Operation uint8 - -const ( - FileSend Operation = 3 - ReadDir Operation = 4 - MakeDir Operation = 5 - FileMove Operation = 6 - FileRemove Operation = 7 - FileInfo Operation = 8 - FileChmod Operation = 9 -) - -const ( - ListWithoutDetails = 0 - ListWithDetails = 1 -) - -const ( - GetFileFromClient = 1 - SendFileToClient = 2 -) - -type FileType uint8 - -const ( - TypeUnknown FileType = 0 - TypeDir FileType = 1 - TypeFile FileType = 2 - TypeCharDevice FileType = 3 - TypeBlockDevice FileType = 4 - TypeNamedPipe FileType = 5 - TypeSymlink FileType = 6 - TypeSocket FileType = 7 -) diff --git a/internal/app/server/files/files.go b/internal/app/server/files/files.go deleted file mode 100644 index 08a6768..0000000 --- a/internal/app/server/files/files.go +++ /dev/null @@ -1,654 +0,0 @@ -package files - -import ( - "context" - "fmt" - "io" - "io/fs" - "os" - "path" - "time" - - "github.com/et-nik/binngo/decode" - "github.com/gameap/daemon/internal/app/fsutil" - "github.com/gameap/daemon/internal/app/server/response" - servercommon "github.com/gameap/daemon/internal/app/server/server_common" - "github.com/gameap/daemon/pkg/logger" - "github.com/pkg/errors" - log "github.com/sirupsen/logrus" -) - -const ( - // uploadStreamIdleTimeout caps the gap between two successful Reads from - // the client. As long as bytes keep flowing it never trips, regardless of - // total upload duration. Tuned for slow networks (Tailscale, NAT) where - // 13 MB at 17 KB/s legitimately takes 13+ minutes. - uploadStreamIdleTimeout = 60 * time.Second -) - -type connDeadlineSetter interface { - SetDeadline(t time.Time) error -} - -type operationHandlerFunc func(ctx context.Context, message anyMessage, readWriter io.ReadWriter) error - -type Files struct { - workPath string - handlers map[Operation]operationHandlerFunc -} - -func NewFiles(workPath string) *Files { - f := &Files{workPath: workPath} - - f.handlers = map[Operation]operationHandlerFunc{ - FileSend: f.fileSend, - ReadDir: f.readDir, - MakeDir: f.makeDir, - FileMove: f.moveCopy, - FileRemove: f.remove, - FileInfo: f.fileInfo, - FileChmod: f.chmod, - } - - return f -} - -// openRoot opens an os.Root at the configured work directory. All legacy file -// operations are confined to it: paths supplied by the client are resolved -// component-by-component through this root, which refuses symlink and ".." -// escapes without TOCTOU races on both Linux and Windows. -func (f *Files) openRoot() (*os.Root, error) { - root, err := os.OpenRoot(f.workPath) - if err != nil { - return nil, errors.Wrap(err, "work directory unavailable") - } - - return root, nil -} - -func (f *Files) Handle(ctx context.Context, readWriter io.ReadWriter) error { - var msg anyMessage - - decoder := decode.NewDecoder(readWriter) - err := decoder.Decode(&msg) - if errors.Is(err, io.EOF) { - return io.EOF - } - if err != nil { - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusError, - Info: "Failed to decode message: " + err.Error(), - }) - } - - if len(msg) == 0 { - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusError, - Info: "Invalid message", - }) - } - - op, err := convertToCode(msg[0]) - if err != nil { - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusError, - Info: "Invalid message", - }) - } - - handler, ok := f.handlers[Operation(op)] - if !ok { - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusError, - Info: "Invalid operation", - }) - } - - if Operation(op) == FileSend { - err = handler(ctx, msg, readWriter) - if err != nil { - return err - } - - return f.Handle(ctx, readWriter) - } - - return handler(ctx, msg, readWriter) -} - -func writeError(readWriter io.Writer, message string) error { - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusError, - Info: message, - }) -} - -func (f *Files) readDir(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { - message, err := createReadDirMessage(m) - if message == nil || err != nil { - return writeError(readWriter, "Invalid message") - } - - root, err := f.openRoot() - if err != nil { - return writeError(readWriter, err.Error()) - } - defer root.Close() - - rel, err := fsutil.RootRel(message.Directory) - if err != nil { - return writeError(readWriter, err.Error()) - } - - dir, err := fs.ReadDir(root.FS(), rel) - if err != nil && errors.Is(err, os.ErrNotExist) { - logger.Logger(ctx).WithFields( - log.Fields{ - "operation": "readDir", - "directory": message.Directory, - }, - ).Debug( - "Directory does not exist", - ) - - return writeError(readWriter, "Directory does not exist") - } - if err != nil { - return err - } - - resp := make([]*fileInfoResponse, len(dir)) - - for i, entry := range dir { - fi, err := entry.Info() - if err != nil { - continue - } - - resp[i] = createFileInfoResponse(fi) - } - - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusOK, - Data: resp, - }) -} - -func (f *Files) makeDir(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { - message, err := createMkDirMessage(m) - if message == nil || err != nil { - return writeError(readWriter, "Invalid message") - } - - root, err := f.openRoot() - if err != nil { - return writeError(readWriter, err.Error()) - } - defer root.Close() - - rel, err := fsutil.RootRel(message.Directory) - if err != nil { - return writeError(readWriter, err.Error()) - } - - err = root.MkdirAll(rel, os.ModePerm) - if err != nil { - logger.Error(ctx, err) - return writeError(readWriter, "Failed to make directory") - } - - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusOK, - }) -} - -func (f *Files) moveCopy(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { - message, err := createMoveMessage(m) - if message == nil || err != nil { - return writeError(readWriter, "Invalid message") - } - - root, err := f.openRoot() - if err != nil { - return writeError(readWriter, err.Error()) - } - defer root.Close() - - srcRel, err := fsutil.RootRel(message.Source) - if err != nil { - return writeError(readWriter, err.Error()) - } - - dstRel, err := fsutil.RootRel(message.Destination) - if err != nil { - return writeError(readWriter, err.Error()) - } - - if _, err := root.Stat(srcRel); errors.Is(err, os.ErrNotExist) { - return writeError(readWriter, fmt.Sprintf("Source \"%s\" not found", message.Source)) - } - - if _, err := root.Stat(dstRel); !errors.Is(err, os.ErrNotExist) { - return writeError(readWriter, fmt.Sprintf("Destination \"%s\" already exists", message.Destination)) - } - - if message.Copy { - err := fsutil.CopyInRoot(root, srcRel, dstRel, fsutil.CopyOptions{Symlink: fsutil.SymlinkShallow}) - if err != nil { - logger.Error(ctx, err) - return writeError(readWriter, "Failed to copy") - } - } else { - err := root.Rename(srcRel, dstRel) - if err != nil { - logger.Error(ctx, err) - return writeError(readWriter, "Failed to move") - } - } - - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusOK, - }) -} - -func (f *Files) fileSend(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { - if len(m) < 2 { - return writeError(readWriter, "Invalid message") - } - - op, err := convertToCode(m[1]) - if err != nil { - return writeError(readWriter, "Invalid message") - } - - err = servercommon.ReadEndBytes(ctx, readWriter) - if err != nil { - return errors.WithMessage(err, "failed to read end bytes") - } - - switch op { - case SendFileToClient: - return f.sendFileToClient(ctx, m, readWriter) - case GetFileFromClient: - return f.getFileFromClient(ctx, m, readWriter) - default: - return writeError(readWriter, "Invalid file send operation") - } -} - -func (f *Files) sendFileToClient(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { - message, err := createSendFileToClientMessage(m) - if message == nil || err != nil { - return writeError(readWriter, "Invalid message") - } - - ctx = logger.WithLogger(ctx, logger.Logger(ctx).WithFields(log.Fields{ - "filepath": message.FilePath, - })) - - root, err := f.openRoot() - if err != nil { - return writeError(readWriter, err.Error()) - } - defer root.Close() - - rel, err := fsutil.RootRel(message.FilePath) - if err != nil { - return writeError(readWriter, err.Error()) - } - - fi, err := root.Stat(rel) - if err != nil { - logger.Error(ctx, err) - return writeError(readWriter, fmt.Sprintf("File \"%s\" error", message.FilePath)) - } - - if !fi.Mode().IsRegular() { - return writeError(readWriter, fmt.Sprintf("\"%s\" is not a file", message.FilePath)) - } - - file, err := root.Open(rel) - - defer func(file *os.File) { - err := file.Close() - if err != nil { - logger.Error(ctx, err) - } - }(file) - - if err != nil { - logger.Error(ctx, err) - return writeError(readWriter, fmt.Sprintf("Failed to open file \"%s\"", message.FilePath)) - } - - err = response.WriteResponse(readWriter, response.Response{ - Code: response.StatusReadyToTransfer, - Info: "File is ready to transfer", - Data: uint64(fi.Size()), - }) - if err != nil { - return err - } - - logger.Debug(ctx, "Starting file transfer") - - _, err = io.Copy(readWriter, file) - if err != nil { - logger.Error(ctx, err) - return writeError(readWriter, "Failed to transfer file") - } - - return nil -} - -//nolint:funlen -func (f *Files) getFileFromClient(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { - message, err := createGetFileFromClientMessage(m) - if message == nil || err != nil { - return writeError(readWriter, "Invalid message") - } - - ctx = logger.WithLogger(ctx, logger.Logger(ctx).WithFields(log.Fields{ - "filepath": message.FilePath, - "filesize": message.FileSize, - })) - - logger.Debug(ctx, "Starting transferring file from client") - - root, err := f.openRoot() - if err != nil { - return writeError(readWriter, err.Error()) - } - defer root.Close() - - rel, err := fsutil.RootRel(message.FilePath) - if err != nil { - return writeError(readWriter, err.Error()) - } - - dir := path.Dir(rel) - _, err = root.Stat(dir) - - //nolint:nestif - if err != nil && errors.Is(err, os.ErrNotExist) { - if message.MakeDirs { - err := root.MkdirAll(dir, 0755) - if err != nil { - logger.Error(ctx, err) - return writeError(readWriter, fmt.Sprintf("Failed to make directory \"%s\"", dir)) - } - } else { - return writeError(readWriter, fmt.Sprintf("File path \"%s\" not found", dir)) - } - } else if err != nil { - logger.Error(ctx, errors.WithMessagef(err, "failed to stat directory \"%s\"", dir)) - return writeError(readWriter, fmt.Sprintf("Directory \"%s\" error", dir)) - } - - var permissions os.FileMode = 0o666 - if stat, statErr := root.Stat(rel); statErr == nil { - permissions = stat.Mode().Perm() - } - - tmpRel := rel + ".upload_tmp" - tmpFile, err := root.OpenFile(tmpRel, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, permissions) - if err != nil { - logger.Error(ctx, errors.WithMessage(err, "failed to create temp file")) - return writeError(readWriter, "Failed to create temp file") - } - defer func() { - // On the success path the temp file has already been closed and - // renamed away; both calls then fail harmlessly. - _ = tmpFile.Close() - _ = root.Remove(tmpRel) - }() - - logger.Logger(ctx).WithFields(log.Fields{ - "filepath": message.FilePath, - "filesize": message.FileSize, - "writer_type": fmt.Sprintf("%T", readWriter), - }).Debug("upload: ready to transfer, sending response") - - err = response.WriteResponse(readWriter, response.Response{ - Code: response.StatusReadyToTransfer, - Info: "File is ready to transfer", - }) - if err != nil { - return errors.WithMessage(err, "failed to write ready to transfer response") - } - - if d, ok := readWriter.(connDeadlineSetter); ok { - if dErr := d.SetDeadline(time.Now().Add(uploadStreamIdleTimeout)); dErr != nil { - logger.Error(ctx, errors.WithMessage(dErr, "failed to set initial upload deadline")) - } else { - logger.Logger(ctx).WithFields(log.Fields{ - "idle_timeout": uploadStreamIdleTimeout.String(), - }).Debug("upload: deadline set with idle-refresh policy") - } - } else { - logger.Logger(ctx).WithFields(log.Fields{ - "writer_type": fmt.Sprintf("%T", readWriter), - }).Warn("upload: readWriter does not implement SetDeadline; using inherited 5s deadline") - } - - copyStart := time.Now() - n, err := copyWithProgress(ctx, tmpFile, readWriter, int64(message.FileSize), uploadStreamIdleTimeout) - copyDuration := time.Since(copyStart) - logger.Logger(ctx).WithFields(log.Fields{ - "copied": n, - "expected": message.FileSize, - "duration": copyDuration.String(), - "rate_kbps": func() int64 { - secs := int64(copyDuration.Seconds()) - if secs <= 0 { - secs = 1 - } - return n / 1024 / secs - }(), - }).Debug("upload: io.CopyN finished") - if err != nil { - logger.Error(ctx, err) - return writeError(readWriter, "Failed to transfer file") - } - - if closeErr := tmpFile.Close(); closeErr != nil { - logger.Error(ctx, errors.WithMessage(closeErr, "failed to close temp file")) - return writeError(readWriter, "Failed to finalize upload") - } - - if chErr := root.Chmod(tmpRel, permissions); chErr != nil { - logger.Error(ctx, errors.WithMessage(chErr, "failed to set file permissions")) - return writeError(readWriter, "Failed to set file permissions") - } - - if mvErr := root.Rename(tmpRel, rel); mvErr != nil { - logger.Error(ctx, errors.WithMessage(mvErr, "failed to move uploaded file into place")) - return writeError(readWriter, "Failed to copy tmp file") - } - - logger.Debug(ctx, "File successfully transferred") - - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusOK, - }) -} - -func (f *Files) remove(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { - msg, err := createRemoveMessage(m) - if msg == nil || err != nil { - return writeError(readWriter, "Invalid message") - } - - root, err := f.openRoot() - if err != nil { - return writeError(readWriter, err.Error()) - } - defer root.Close() - - rel, err := fsutil.RootRel(msg.Path) - if err != nil { - return writeError(readWriter, err.Error()) - } - - if rel == "." { - return writeError(readWriter, "Invalid path") - } - - if _, err = root.Stat(rel); errors.Is(err, os.ErrNotExist) { - return writeError(readWriter, "Path not exist") - } - - if msg.Recursive { - err = root.RemoveAll(rel) - } else { - err = root.Remove(rel) - } - - if err != nil { - logger.Error(ctx, err) - return writeError(readWriter, "Failed to remove") - } - - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusOK, - }) -} - -func (f *Files) fileInfo(_ context.Context, m anyMessage, readWriter io.ReadWriter) error { - msg, err := createFileInfoMessage(m) - if msg == nil || err != nil { - return writeError(readWriter, "Invalid message") - } - - root, err := f.openRoot() - if err != nil { - return writeError(readWriter, err.Error()) - } - defer root.Close() - - rel, err := fsutil.RootRel(msg.Path) - if err != nil { - return writeError(readWriter, err.Error()) - } - - r, err := createfileDetailsResponse(root, rel) - if err != nil { - return writeError(readWriter, "Failed to read file details") - } - - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusOK, - Data: r, - }) -} - -func (f *Files) chmod(_ context.Context, m anyMessage, readWriter io.ReadWriter) error { - msg, err := createChmodMessage(m) - if msg == nil || err != nil { - return writeError(readWriter, "Invalid message") - } - - root, err := f.openRoot() - if err != nil { - return writeError(readWriter, err.Error()) - } - defer root.Close() - - rel, err := fsutil.RootRel(msg.Path) - if err != nil { - return writeError(readWriter, err.Error()) - } - - err = root.Chmod(rel, os.FileMode(msg.Perm)) - if err != nil && errors.Is(err, os.ErrNotExist) { - return writeError(readWriter, "Path not exist") - } - if err != nil { - return writeError(readWriter, "Failed to change permissions") - } - - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusOK, - }) -} - -// copyWithProgress mirrors io.CopyN with two extras: -// - logs throughput every 5 seconds so we can tell stalls from slow streams. -// - if src is a connDeadlineSetter, refreshes the deadline on every successful -// Read so genuinely-slow networks (e.g. Tailscale, VPN) can take as long as -// they need provided bytes keep flowing. Idle stalls still trigger timeout. -func copyWithProgress( - ctx context.Context, - dst io.Writer, - src io.Reader, - n int64, - idleTimeout time.Duration, -) (int64, error) { - const ( - bufSize = 32 * 1024 - logInterval = 5 * time.Second - ) - - var deadlineConn connDeadlineSetter - if idleTimeout > 0 { - if c, ok := src.(connDeadlineSetter); ok { - deadlineConn = c - } - } - - buf := make([]byte, bufSize) - var copied int64 - lastLog := time.Now() - lastBytes := int64(0) - - for copied < n { - toRead := int64(bufSize) - if remaining := n - copied; remaining < toRead { - toRead = remaining - } - - readStart := time.Now() - nr, rerr := src.Read(buf[:toRead]) - if nr > 0 { - if deadlineConn != nil { - _ = deadlineConn.SetDeadline(time.Now().Add(idleTimeout)) - } - - nw, werr := dst.Write(buf[:nr]) - copied += int64(nw) - if werr != nil { - return copied, werr - } - if nw != nr { - return copied, io.ErrShortWrite - } - } - - if time.Since(lastLog) >= logInterval { - elapsed := time.Since(lastLog) - delta := copied - lastBytes - rateKBps := float64(delta) / elapsed.Seconds() / 1024.0 - logger.Logger(ctx).WithFields(log.Fields{ - "copied": copied, - "expected": n, - "delta_since_last": delta, - "interval": elapsed.String(), - "rate_kbps": fmt.Sprintf("%.1f", rateKBps), - "last_read_size": nr, - "last_read_latency": time.Since(readStart).String(), - }).Debug("upload: progress") - lastLog = time.Now() - lastBytes = copied - } - - if rerr != nil { - if rerr == io.EOF && copied == n { - return copied, nil - } - - return copied, rerr - } - } - - return copied, nil -} diff --git a/internal/app/server/files/os_utils.go b/internal/app/server/files/os_utils.go deleted file mode 100644 index 6eefdf2..0000000 --- a/internal/app/server/files/os_utils.go +++ /dev/null @@ -1,6 +0,0 @@ -package files - -type fileTime struct { - AccessTime uint64 - CreatingTime uint64 -} diff --git a/internal/app/server/files/os_utils_darwin.go b/internal/app/server/files/os_utils_darwin.go deleted file mode 100644 index 121cfd7..0000000 --- a/internal/app/server/files/os_utils_darwin.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build darwin -// +build darwin - -package files - -import ( - "os" - "syscall" -) - -func fileTimeFromFileInfo(fileInfo os.FileInfo) fileTime { - sys := fileInfo.Sys().(*syscall.Stat_t) - - return fileTime{ - AccessTime: uint64(sys.Atimespec.Sec), - CreatingTime: uint64(sys.Ctimespec.Sec), - } -} diff --git a/internal/app/server/files/os_utils_linux.go b/internal/app/server/files/os_utils_linux.go deleted file mode 100644 index da689eb..0000000 --- a/internal/app/server/files/os_utils_linux.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build linux -// +build linux - -package files - -import ( - "os" - "syscall" -) - -func fileTimeFromFileInfo(fileInfo os.FileInfo) fileTime { - sys := fileInfo.Sys().(*syscall.Stat_t) - - return fileTime{ - AccessTime: uint64(sys.Atim.Sec), - CreatingTime: uint64(sys.Ctim.Sec), - } -} diff --git a/internal/app/server/files/os_utils_windows.go b/internal/app/server/files/os_utils_windows.go deleted file mode 100644 index 4f2aada..0000000 --- a/internal/app/server/files/os_utils_windows.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build windows -// +build windows - -package files - -import ( - "os" - "syscall" - "time" -) - -func fileTimeFromFileInfo(fileInfo os.FileInfo) fileTime { - sys := fileInfo.Sys().(*syscall.Win32FileAttributeData) - return fileTime{ - AccessTime: uint64(sys.LastAccessTime.Nanoseconds() / int64(time.Second)), - CreatingTime: uint64(sys.CreationTime.Nanoseconds() / int64(time.Second)), - } -} diff --git a/internal/app/server/files/requests.go b/internal/app/server/files/requests.go deleted file mode 100644 index 8b81dfd..0000000 --- a/internal/app/server/files/requests.go +++ /dev/null @@ -1,240 +0,0 @@ -package files - -import ( - "errors" - "os" -) - -var errInvalidMessage = errors.New("unknown binn value, cannot be presented as struct") - -type anyMessage []interface{} - -func convertToCode(val interface{}) (uint8, error) { - switch v := val.(type) { - case uint8: - return v, nil - case int8: - return uint8(v), nil - default: - return 0, errInvalidMessage - } -} - -func convertToUint64(val interface{}) (uint64, error) { - switch v := val.(type) { - case uint: - return uint64(v), nil - case int: - return uint64(v), nil - case uint8: - return uint64(v), nil - case int8: - return uint64(v), nil - case uint16: - return uint64(v), nil - case int16: - return uint64(v), nil - case uint32: - return uint64(v), nil - case int32: - return uint64(v), nil - case uint64: - return v, nil - case int64: - return uint64(v), nil - default: - return 0, errInvalidMessage - } -} - -type readDirMessage struct { - Directory string - DetailsMode bool -} - -func createReadDirMessage(m anyMessage) (*readDirMessage, error) { - if len(m) < 3 { - return nil, errInvalidMessage - } - - directory, ok := m[1].(string) - if !ok { - return nil, errInvalidMessage - } - - detailsMode, err := convertToCode(m[2]) - if err != nil { - return nil, err - } - - return &readDirMessage{ - directory, - detailsMode != 0, - }, nil -} - -type mkDirMessage struct { - Directory string -} - -func createMkDirMessage(m anyMessage) (*mkDirMessage, error) { - if len(m) < 2 { - return nil, errInvalidMessage - } - - directory, ok := m[1].(string) - if !ok { - return nil, errInvalidMessage - } - - return &mkDirMessage{directory}, nil -} - -type moveMessage struct { - Source string - Destination string - Copy bool -} - -func createMoveMessage(m anyMessage) (*moveMessage, error) { - if len(m) < 3 { - return nil, errInvalidMessage - } - - source, ok := m[1].(string) - if !ok { - return nil, errInvalidMessage - } - - destination, ok := m[2].(string) - if !ok { - return nil, errInvalidMessage - } - - cp, ok := m[3].(bool) - if !ok { - return nil, errInvalidMessage - } - - return &moveMessage{source, destination, cp}, nil -} - -type sendFileToClientMessage struct { - FilePath string -} - -func createSendFileToClientMessage(m anyMessage) (*sendFileToClientMessage, error) { - if len(m) < 3 { - return nil, errInvalidMessage - } - - filePath, ok := m[2].(string) - if !ok { - return nil, errInvalidMessage - } - - return &sendFileToClientMessage{filePath}, nil -} - -type getFileFromClientMessage struct { - FilePath string - FileSize uint64 - MakeDirs bool - Perms os.FileMode -} - -func createGetFileFromClientMessage(m anyMessage) (*getFileFromClientMessage, error) { - if len(m) < 6 { - return nil, errInvalidMessage - } - - filePath, ok := m[2].(string) - if !ok { - return nil, errInvalidMessage - } - - fileSize, err := convertToUint64(m[3]) - if err != nil { - return nil, errInvalidMessage - } - - makeDirs, ok := m[4].(bool) - if !ok { - return nil, errInvalidMessage - } - - perms, err := convertToUint64(m[5]) - if err != nil { - return nil, errInvalidMessage - } - - return &getFileFromClientMessage{ - FilePath: filePath, - FileSize: fileSize, - MakeDirs: makeDirs, - Perms: os.FileMode(perms), - }, nil -} - -type removeMessage struct { - Path string - Recursive bool -} - -func createRemoveMessage(m anyMessage) (*removeMessage, error) { - if len(m) < 3 { - return nil, errInvalidMessage - } - - path, ok := m[1].(string) - if !ok { - return nil, errInvalidMessage - } - - recursive, ok := m[2].(bool) - if !ok { - return nil, errInvalidMessage - } - - return &removeMessage{path, recursive}, nil -} - -type fileInfoMessage struct { - Path string -} - -func createFileInfoMessage(m anyMessage) (*fileInfoMessage, error) { - if len(m) < 2 { - return nil, errInvalidMessage - } - - path, ok := m[1].(string) - if !ok { - return nil, errInvalidMessage - } - - return &fileInfoMessage{path}, nil -} - -type chmodMessage struct { - Path string - Perm uint32 -} - -func createChmodMessage(m anyMessage) (*chmodMessage, error) { - if len(m) < 3 { - return nil, errInvalidMessage - } - - path, ok := m[1].(string) - if !ok { - return nil, errInvalidMessage - } - - perm, err := convertToUint64(m[2]) - if err != nil { - return nil, err - } - - return &chmodMessage{path, uint32(perm)}, nil -} diff --git a/internal/app/server/files/response.go b/internal/app/server/files/response.go deleted file mode 100644 index a52c4f2..0000000 --- a/internal/app/server/files/response.go +++ /dev/null @@ -1,120 +0,0 @@ -package files - -import ( - "os" - - "github.com/et-nik/binngo" - "github.com/gabriel-vasile/mimetype" -) - -func fileTypeByMode(fileMode os.FileMode) FileType { - fType := TypeUnknown - - switch { - case fileMode&os.ModeSymlink != 0: - fType = TypeSymlink - case fileMode.IsRegular(): - fType = TypeFile - case fileMode.IsDir(): - fType = TypeDir - case fileMode&os.ModeCharDevice != 0: - fType = TypeCharDevice - case fileMode&os.ModeDevice != 0: - fType = TypeBlockDevice - case fileMode&os.ModeNamedPipe != 0: - fType = TypeNamedPipe - case fileMode&os.ModeSocket != 0: - fType = TypeSocket - } - - return fType -} - -type fileInfoResponse struct { - Name string - Size uint64 - TimeModified uint64 - Type uint8 - Perm uint32 -} - -func createFileInfoResponse(fi os.FileInfo) *fileInfoResponse { - fType := fileTypeByMode(fi.Mode()) - - return &fileInfoResponse{ - Name: fi.Name(), - Size: uint64(fi.Size()), - TimeModified: uint64(fi.ModTime().Unix()), - Type: uint8(fType), - Perm: uint32(fi.Mode().Perm()), - } -} - -func (fi fileInfoResponse) MarshalBINN() ([]byte, error) { - resp := []interface{}{fi.Name, fi.Size, fi.TimeModified, fi.Type, fi.Perm} - return binngo.Marshal(&resp) -} - -//nolint:maligned -type fileDetailsResponse struct { - Name string - Mime string - Size uint64 - ModificationTime uint64 - AccessTime uint64 - CreateTime uint64 - Perm uint32 - Type uint8 -} - -func createfileDetailsResponse(root *os.Root, rel string) (*fileDetailsResponse, error) { - fi, err := root.Lstat(rel) - if err != nil { - return nil, err - } - fType := fileTypeByMode(fi.Mode()) - - fileTime := fileTimeFromFileInfo(fi) - - var mime string - if fType == TypeFile && fi.Size() > 0 { - file, err := root.Open(rel) - if err != nil { - return nil, err - } - - mm, err := mimetype.DetectReader(file) - _ = file.Close() - - if err != nil { - return nil, err - } - - mime = mm.String() - } - - return &fileDetailsResponse{ - Name: fi.Name(), - Size: uint64(fi.Size()), - Type: uint8(fType), - ModificationTime: uint64(fi.ModTime().Unix()), - AccessTime: fileTime.AccessTime, - CreateTime: fileTime.CreatingTime, - Perm: uint32(fi.Mode().Perm()), - Mime: mime, - }, nil -} - -func (fdr fileDetailsResponse) MarshalBINN() ([]byte, error) { - resp := []interface{}{ - fdr.Name, - fdr.Size, - fdr.Type, - fdr.ModificationTime, - fdr.AccessTime, - fdr.CreateTime, - fdr.Perm, - fdr.Mime, - } - return binngo.Marshal(&resp) -} diff --git a/internal/app/server/messages.go b/internal/app/server/messages.go deleted file mode 100644 index e6296dc..0000000 --- a/internal/app/server/messages.go +++ /dev/null @@ -1,75 +0,0 @@ -package server - -import ( - "errors" - - "github.com/et-nik/binngo/decode" -) - -var errUnknownValueAuthMessage = errors.New("cannot be presented as authMessage") - -type authMessage struct { - Login string - Password string - Mode Mode -} - -func createAuthMessageFromSliceInterface(v []interface{}) (*authMessage, error) { - if len(v) < 4 { - return nil, errUnknownValueAuthMessage - } - - login, ok := v[1].(string) - if !ok { - return nil, errUnknownValueAuthMessage - } - - password, ok := v[2].(string) - if !ok { - return nil, errUnknownValueAuthMessage - } - - md := convertToMode(v[3]) - if md == ModeUnknown { - return nil, errUnknownValueAuthMessage - } - - return &authMessage{ - login, - password, - md, - }, nil -} - -func (am *authMessage) UnmarshalBINN(bytes []byte) error { - var v []interface{} - - err := decode.Unmarshal(bytes, &v) - if err != nil { - return err - } - - a, err := createAuthMessageFromSliceInterface(v) - if err != nil { - return err - } - - am.Login = a.Login - am.Password = a.Password - am.Mode = a.Mode - - return nil -} - -func convertToMode(val interface{}) Mode { - switch v := val.(type) { - case uint8: - return Mode(v) - case uint16: - return Mode(v) - case uint32: - return Mode(v) - default: - return ModeUnknown - } -} diff --git a/internal/app/server/response/status.go b/internal/app/server/response/status.go deleted file mode 100644 index ed337fc..0000000 --- a/internal/app/server/response/status.go +++ /dev/null @@ -1,89 +0,0 @@ -package response - -import ( - "io" - - "github.com/et-nik/binngo" - "github.com/et-nik/binngo/decode" - "github.com/et-nik/binngo/encode" - "github.com/pkg/errors" -) - -var errUnknownBinn = errors.New("unknown binn value, cannot be presented as status") - -type Response struct { - Data interface{} - Info string - Code Code -} - -type Code uint8 - -const ( - StatusError Code = 1 - StatusCriticalError Code = 2 - StatusUnknownCommand Code = 3 - StatusOK Code = 100 - StatusReadyToTransfer Code = 101 -) - -func (r Response) MarshalBINN() ([]byte, error) { - response := []interface{}{r.Code, r.Info} - - if r.Data != nil { - response = append(response, r.Data) - } - - return binngo.Marshal(&response) -} - -func (r *Response) UnmarshalBINN(bytes []byte) error { - var v []interface{} - - err := decode.Unmarshal(bytes, &v) - if err != nil { - return err - } - if len(v) < 2 { - return errUnknownBinn - } - - var code Code - - switch val := v[0].(type) { - case uint8: - code = Code(val) - case uint16: - code = Code(val) - case uint32: - code = Code(val) - default: - return errUnknownBinn - } - - info, ok := v[1].(string) - if !ok { - return errUnknownBinn - } - - r.Code = code - r.Info = info - - return nil -} - -func WriteResponse(writer io.Writer, r encode.Marshaler) error { - writeBytes, err := binngo.Marshal(&r) - if err != nil { - return errors.WithMessage(err, "failed to marshal response") - } - - writeBytes = append(writeBytes, []byte{0xFF, 0xFF, 0xFF, 0xFF}...) - - _, err = writer.Write(writeBytes) - if err != nil { - return errors.WithMessage(err, "failed to write response") - } - - return nil -} diff --git a/internal/app/server/server.go b/internal/app/server/server.go deleted file mode 100644 index 71737db..0000000 --- a/internal/app/server/server.go +++ /dev/null @@ -1,263 +0,0 @@ -package server - -import ( - "context" - "crypto/tls" - "fmt" - "io" - "net" - "sync" - "time" - - "github.com/et-nik/binngo/decode" - "github.com/gameap/daemon/internal/app/contracts" - "github.com/gameap/daemon/internal/app/domain" - "github.com/gameap/daemon/internal/app/server/commands" - "github.com/gameap/daemon/internal/app/server/files" - "github.com/gameap/daemon/internal/app/server/response" - servercommon "github.com/gameap/daemon/internal/app/server/server_common" - "github.com/gameap/daemon/internal/app/server/status" - "github.com/gameap/daemon/pkg/logger" - "github.com/pkg/errors" - log "github.com/sirupsen/logrus" -) - -var errInvalidMode = errors.New("invalid server mode") - -type CredentialsConfig struct { - Login string - Password string - PasswordAuthentication bool -} - -type Server struct { - listener net.Listener - executor contracts.Executor - taskStatsReader domain.GDTaskStatsReader - - quit chan struct{} - - ip string - workPath string - certPEM []byte - keyPEM []byte - credConfig CredentialsConfig - wg sync.WaitGroup - port int - connTimeout time.Duration -} - -type componentHandler interface { - Handle(ctx context.Context, readWriter io.ReadWriter) error -} - -func NewServer( - ip string, - port int, - workPath string, - certPEM []byte, - keyPEM []byte, - credConfig CredentialsConfig, - executor contracts.Executor, - taskStatsReader domain.GDTaskStatsReader, -) (*Server, error) { - return &Server{ - ip: ip, - port: port, - workPath: workPath, - certPEM: certPEM, - keyPEM: keyPEM, - credConfig: credConfig, - quit: make(chan struct{}), - connTimeout: 5 * time.Second, - executor: executor, - taskStatsReader: taskStatsReader, - }, nil -} - -func (srv *Server) Run(ctx context.Context) error { - cer, err := tls.X509KeyPair(srv.certPEM, srv.keyPEM) - if err != nil { - return err - } - - config := &tls.Config{ - Certificates: []tls.Certificate{cer}, - MinVersion: tls.VersionTLS12, - CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, - CipherSuites: []uint16{ - tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, - tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, - tls.TLS_CHACHA20_POLY1305_SHA256, - }, - } - - listener, err := tls.Listen("tcp", fmt.Sprintf("%s:%d", srv.ip, srv.port), config) - if err != nil { - return err - } - - srv.listener = listener - srv.wg.Add(1) - logger.Infof(ctx, "GameAP Daemon server listening at: %s:%d", srv.ip, srv.port) - - go func() { - <-ctx.Done() - logger.Info(ctx, "Server shutting down...") - srv.Stop(ctx) - }() - - return srv.serve(ctx) -} - -func (srv *Server) serve(ctx context.Context) error { - defer srv.wg.Done() - - for { - conn, err := srv.listener.Accept() - if err != nil { - select { - case <-srv.quit: - return nil - default: - logger.Info(ctx, "Accept error") - return err - } - } - - srv.wg.Add(1) - go func() { - err = srv.handleConnection(ctx, conn) - if err != nil && !errors.Is(err, io.EOF) { - logger.WithError(ctx, err).Warn("Handle connection") - } - - logger.Tracef(ctx, "Closing connection from %s", conn.RemoteAddr()) - err = conn.Close() - if err != nil { - logger.WithError(ctx, err).Warn("Failed to close connection") - } - - srv.wg.Done() - }() - } -} - -func (srv *Server) handleConnection(ctx context.Context, conn net.Conn) error { - err := conn.SetDeadline(time.Now().Add(srv.connTimeout)) - if err != nil { - return err - } - - ctx = logger.WithLogger(ctx, logger.Logger(ctx).WithFields(log.Fields{ - "client": conn.RemoteAddr(), - })) - - var msg []interface{} - decoder := decode.NewDecoder(conn) - err = decoder.Decode(&msg) - if errors.Is(err, io.EOF) { - return nil - } - if err != nil { - logger.WithError(ctx, err).Warn("failed to decode message") - return errors.WithMessage(err, "failed to decode message") - } - - authMsg, err := createAuthMessageFromSliceInterface(msg) - if err != nil { - logger.WithError(ctx, err).Warn("failed to create auth message") - - return response.WriteResponse(conn, response.Response{ - Code: response.StatusError, - Info: "Invalid message", - }) - } - - if !srv.auth(authMsg.Login, authMsg.Password) { - return response.WriteResponse(conn, response.Response{ - Code: response.StatusError, - Info: "Auth failed", - }) - } - - err = response.WriteResponse(conn, response.Response{ - Code: response.StatusOK, - Info: "Auth success", - }) - if err != nil { - return errors.WithMessage(err, "failed to write auth response") - } - - err = servercommon.ReadEndBytes(ctx, conn) - if err != nil { - return err - } - - return srv.serveComponent(ctx, conn, authMsg.Mode) -} - -func (srv *Server) auth(login string, password string) bool { - if srv.credConfig.PasswordAuthentication { - if srv.credConfig.Login != login || srv.credConfig.Password != password { - return false - } - } - - return true -} - -func (srv *Server) serveComponent(ctx context.Context, conn net.Conn, m Mode) error { - var handler componentHandler - switch m { - case ModeCommands: - handler = commands.NewCommands(srv.executor) - case ModeFiles: - handler = files.NewFiles(srv.workPath) - case ModeStatus: - handler = status.NewStatus(srv.taskStatsReader) - default: - err := response.WriteResponse(conn, response.Response{ - Code: response.StatusError, - Info: "Invalid mode", - }) - if err != nil { - logger.WithError(ctx, err).Warn("Failed to write response") - return err - } - - return errInvalidMode - } - - for { - select { - case <-srv.quit: - return nil - default: - err := conn.SetDeadline(time.Now().Add(srv.connTimeout)) - if err != nil { - return err - } - - err = handler.Handle(ctx, conn) - if err != nil { - return err - } - - err = servercommon.ReadEndBytes(ctx, conn) - if err != nil { - return err - } - } - } -} - -func (srv *Server) Stop(ctx context.Context) { - close(srv.quit) - err := srv.listener.Close() - if err != nil { - logger.WithError(ctx, err).Error("Failed to stop server") - } - srv.wg.Wait() -} diff --git a/internal/app/server/server_common/common.go b/internal/app/server/server_common/common.go deleted file mode 100644 index a5b3be5..0000000 --- a/internal/app/server/server_common/common.go +++ /dev/null @@ -1,28 +0,0 @@ -package servercommon - -import ( - "bytes" - "context" - "io" - - "github.com/pkg/errors" -) - -var ErrInvalidEndBytes = errors.New("invalid message end bytes") - -func ReadEndBytes(_ context.Context, reader io.Reader) error { - endBytes := make([]byte, 4) - _, err := reader.Read(endBytes) - if errors.Is(err, io.EOF) { - return nil - } - if err != nil { - return err - } - - if !bytes.Equal(endBytes, []byte{0xFF, 0xFF, 0xFF, 0xFF}) { - return ErrInvalidEndBytes - } - - return nil -} diff --git a/internal/app/server/status/enum.go b/internal/app/server/status/enum.go deleted file mode 100644 index bcd2de1..0000000 --- a/internal/app/server/status/enum.go +++ /dev/null @@ -1,32 +0,0 @@ -package status - -import ( - "github.com/et-nik/binngo/decode" - "github.com/pkg/errors" -) - -type Operation uint8 - -const ( - Version Operation = 1 - StatusBase Operation = 2 - StatusDetails Operation = 3 -) - -var errInvalidOperationMessage = errors.New("unknown binn value, cannot be presented as operation") - -func (o *Operation) UnmarshalBINN(bytes []byte) error { - var v []uint8 - - err := decode.Unmarshal(bytes, &v) - if err != nil { - return err - } - if len(v) < 1 { - return errInvalidOperationMessage - } - - *o = Operation(v[0]) - - return nil -} diff --git a/internal/app/server/status/response.go b/internal/app/server/status/response.go deleted file mode 100644 index 9ece754..0000000 --- a/internal/app/server/status/response.go +++ /dev/null @@ -1,38 +0,0 @@ -package status - -import ( - "github.com/et-nik/binngo" - "github.com/gameap/daemon/internal/app/server/response" -) - -type versionResponse struct { - Version string - BuildDate string -} - -func (r *versionResponse) MarshalBINN() ([]byte, error) { - resp := []interface{}{ - response.StatusOK, - r.Version, - r.BuildDate, - } - return binngo.Marshal(&resp) -} - -type infoBaseResponse struct { - Uptime string - WorkingTasks string - WaitingTasks string - OnlineServers string -} - -func (r *infoBaseResponse) MarshalBINN() ([]byte, error) { - resp := []interface{}{ - response.StatusOK, - r.Uptime, - r.WorkingTasks, - r.WaitingTasks, - r.OnlineServers, - } - return binngo.Marshal(&resp) -} diff --git a/internal/app/server/status/status.go b/internal/app/server/status/status.go deleted file mode 100644 index 261b21a..0000000 --- a/internal/app/server/status/status.go +++ /dev/null @@ -1,85 +0,0 @@ -package status - -import ( - "context" - "io" - "strconv" - "time" - - "github.com/et-nik/binngo/decode" - "github.com/gameap/daemon/internal/app/build" - "github.com/gameap/daemon/internal/app/domain" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/pkg/errors" -) - -type operationHandlerFunc func(readWriter io.ReadWriter) error - -type Status struct { - gdTaskStatsReader domain.GDTaskStatsReader - handlers map[Operation]operationHandlerFunc -} - -func NewStatus(gdTaskStatsReader domain.GDTaskStatsReader) *Status { - status := &Status{ - gdTaskStatsReader: gdTaskStatsReader, - } - - status.handlers = map[Operation]operationHandlerFunc{ - Version: status.version, - StatusBase: status.statusBase, - StatusDetails: status.statusDetails, - } - - return status -} - -func (s *Status) Handle(_ context.Context, readWriter io.ReadWriter) error { - var operation Operation - decoder := decode.NewDecoder(readWriter) - err := decoder.Decode(&operation) - if errors.Is(err, io.EOF) { - return io.EOF - } - if err != nil { - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusError, - Info: "Failed to decode message", - }) - } - - handler, ok := s.handlers[operation] - if !ok { - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusError, - Info: "Invalid operation", - }) - } - - return handler(readWriter) -} - -func (s *Status) version(readWriter io.ReadWriter) error { - return response.WriteResponse(readWriter, &versionResponse{ - build.Version, - build.BuildDate, - }) -} - -func (s *Status) statusBase(readWriter io.ReadWriter) error { - stats := s.gdTaskStatsReader.Stats() - - return response.WriteResponse(readWriter, &infoBaseResponse{ - Uptime: time.Since(domain.StartTime).Truncate(1 * time.Second).String(), - WorkingTasks: strconv.Itoa(stats.WorkingCount), - WaitingTasks: strconv.Itoa(stats.WaitingCount), - OnlineServers: "-", - }) -} - -func (s *Status) statusDetails(readWriter io.ReadWriter) error { - return response.WriteResponse(readWriter, response.Response{ - Code: response.StatusError, - Info: "Not implemented", - }) -} diff --git a/internal/app/servers_scheduler/cache.go b/internal/app/servers_scheduler/cache.go new file mode 100644 index 0000000..004225f --- /dev/null +++ b/internal/app/servers_scheduler/cache.go @@ -0,0 +1,66 @@ +package serversscheduler + +import ( + "sync" + + "github.com/gameap/daemon/internal/app/domain" +) + +type taskCache struct { + mu sync.Mutex + tasks map[uint64]*domain.ServerTask +} + +func newTaskCache() *taskCache { + return &taskCache{tasks: make(map[uint64]*domain.ServerTask)} +} + +func (c *taskCache) Get(id uint64) *domain.ServerTask { + c.mu.Lock() + defer c.mu.Unlock() + + return c.tasks[id] +} + +func (c *taskCache) Put(t *domain.ServerTask) { + c.mu.Lock() + defer c.mu.Unlock() + + c.tasks[t.ID()] = t +} + +func (c *taskCache) Delete(id uint64) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.tasks, id) +} + +func (c *taskCache) Replace(tasks []*domain.ServerTask) { + c.mu.Lock() + defer c.mu.Unlock() + + c.tasks = make(map[uint64]*domain.ServerTask, len(tasks)) + for _, t := range tasks { + c.tasks[t.ID()] = t + } +} + +func (c *taskCache) Snapshot() []*domain.ServerTask { + c.mu.Lock() + defer c.mu.Unlock() + + out := make([]*domain.ServerTask, 0, len(c.tasks)) + for _, t := range c.tasks { + out = append(out, t) + } + + return out +} + +func (c *taskCache) Len() int { + c.mu.Lock() + defer c.mu.Unlock() + + return len(c.tasks) +} diff --git a/internal/app/servers_scheduler/execution.go b/internal/app/servers_scheduler/execution.go new file mode 100644 index 0000000..d8d85c0 --- /dev/null +++ b/internal/app/servers_scheduler/execution.go @@ -0,0 +1,139 @@ +package serversscheduler + +import ( + "context" + "time" + + "github.com/gameap/daemon/internal/app/domain" + gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" + "github.com/gameap/daemon/pkg/logger" + pb "github.com/gameap/gameap/pkg/proto" + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func (s *Scheduler) executeNow(parent context.Context, rec *executionRecord, server *domain.Server) { + ctx, cancel := context.WithCancel(parent) + + s.mu.Lock() + rec.cancel = cancel + s.mu.Unlock() + + defer cancel() + defer s.completeAndAdvance(parent, rec) + + s.sendStarted(rec) + + domainCmd, ok := mapProtoCommandToDomain(rec.command) + if !ok { + s.sendFinished(rec, + pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_FAILED, + "unknown server task command", nil, s.now()) + return + } + + cmd := s.commandLoader.LoadServerCommand(domainCmd, server) + if cmd == nil { + s.sendFinished(rec, + pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_FAILED, + "no server command implementation", nil, s.now()) + return + } + + err := cmd.Execute(ctx, server) + output := cmd.ReadOutput() + finishedAt := s.now() + + status := pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_SUCCESS + var errMsg string + + switch { + case ctx.Err() != nil && parent.Err() == nil: + status = pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_CANCELED + case err != nil: + status = pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_FAILED + errMsg = truncateError(err.Error()) + logger.Logger(parent).WithError(err).WithField("task_id", rec.taskID). + Warn("Server task command failed") + case cmd.Result() == gameservercommands.ErrorResult: + status = pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_FAILED + } + + if status == pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_SUCCESS { + server.NoticeTaskCompleted() + } + + s.sendFinished(rec, status, errMsg, output, finishedAt) +} + +func (s *Scheduler) sendStarted(rec *executionRecord) { + s.sender.Send(&pb.DaemonMessage{ + Payload: &pb.DaemonMessage_ServerTaskExecutionStarted{ + ServerTaskExecutionStarted: &pb.ServerTaskExecutionStarted{ + ExecutionId: rec.execID, + TaskId: rec.taskID, + TaskVersion: rec.taskVersion, + ServerId: rec.serverID, + NodeId: rec.nodeID, + Command: rec.command, + StartedAt: timestamppb.New(rec.startedAt), + }, + }, + }) +} + +func (s *Scheduler) sendFinishedSkipped(rec *executionRecord) { + now := s.now() + s.sendFinished(rec, + pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_SKIPPED, + "overlap policy SKIP: previous execution still running", nil, now) +} + +func (s *Scheduler) sendFinished( + rec *executionRecord, + status pb.ServerTaskExecutionStatus, + errMsg string, + output []byte, + finishedAt time.Time, +) { + chunks, inlineTail, streamed := splitOutput(output) + + if streamed { + for i, chunk := range chunks { + s.sender.Send(&pb.DaemonMessage{ + Payload: &pb.DaemonMessage_ServerTaskExecutionLog{ + ServerTaskExecutionLog: &pb.ServerTaskExecutionLog{ + ExecutionId: rec.execID, + Sequence: uint64(i + 1), + Chunk: chunk, + IsFinal: i == len(chunks)-1, + }, + }, + }) + } + } + + s.sender.Send(&pb.DaemonMessage{ + Payload: &pb.DaemonMessage_ServerTaskExecutionFinished{ + ServerTaskExecutionFinished: &pb.ServerTaskExecutionFinished{ + ExecutionId: rec.execID, + TaskId: rec.taskID, + Status: status, + ExitCode: 0, + ErrorMessage: errMsg, + FinishedAt: timestamppb.New(finishedAt), + Duration: durationpb.New(finishedAt.Sub(rec.startedAt)), + OutputInline: inlineTail, + OutputStreamed: streamed, + OutputStoragePath: "", + }, + }, + }) + + log.WithFields(log.Fields{ + "execution_id": rec.execID, + "task_id": rec.taskID, + "status": status.String(), + }).Debug("Server task execution finished") +} diff --git a/internal/app/servers_scheduler/helpers_test.go b/internal/app/servers_scheduler/helpers_test.go new file mode 100644 index 0000000..0882069 --- /dev/null +++ b/internal/app/servers_scheduler/helpers_test.go @@ -0,0 +1,198 @@ +package serversscheduler + +import ( + "context" + "strconv" + "sync" + "time" + + "github.com/gameap/daemon/internal/app/contracts" + "github.com/gameap/daemon/internal/app/domain" + gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" + pb "github.com/gameap/gameap/pkg/proto" +) + +type fakeSender struct { + mu sync.Mutex + msgs []*pb.DaemonMessage +} + +func newFakeSender() *fakeSender { + return &fakeSender{} +} + +func (s *fakeSender) Send(msg *pb.DaemonMessage) { + s.mu.Lock() + defer s.mu.Unlock() + + s.msgs = append(s.msgs, msg) +} + +func (s *fakeSender) Messages() []*pb.DaemonMessage { + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]*pb.DaemonMessage, len(s.msgs)) + copy(out, s.msgs) + return out +} + +func (s *fakeSender) Started() []*pb.ServerTaskExecutionStarted { + out := []*pb.ServerTaskExecutionStarted{} + for _, m := range s.Messages() { + if v := m.GetServerTaskExecutionStarted(); v != nil { + out = append(out, v) + } + } + return out +} + +func (s *fakeSender) Finished() []*pb.ServerTaskExecutionFinished { + out := []*pb.ServerTaskExecutionFinished{} + for _, m := range s.Messages() { + if v := m.GetServerTaskExecutionFinished(); v != nil { + out = append(out, v) + } + } + return out +} + +func (s *fakeSender) Logs() []*pb.ServerTaskExecutionLog { + out := []*pb.ServerTaskExecutionLog{} + for _, m := range s.Messages() { + if v := m.GetServerTaskExecutionLog(); v != nil { + out = append(out, v) + } + } + return out +} + +func (s *fakeSender) ResyncRequests() []*pb.ServerTaskResyncRequest { + out := []*pb.ServerTaskResyncRequest{} + for _, m := range s.Messages() { + if v := m.GetServerTaskResyncRequest(); v != nil { + out = append(out, v) + } + } + return out +} + +type fakeServerRepo struct { + mu sync.Mutex + servers map[int]*domain.Server +} + +func newFakeServerRepo(servers ...*domain.Server) *fakeServerRepo { + r := &fakeServerRepo{servers: make(map[int]*domain.Server)} + for _, s := range servers { + r.servers[s.ID()] = s + } + return r +} + +func (r *fakeServerRepo) FindByID(_ context.Context, id int) (*domain.Server, error) { + r.mu.Lock() + defer r.mu.Unlock() + + return r.servers[id], nil +} + +func (r *fakeServerRepo) Save(_ context.Context, _ *domain.Server) error { + return nil +} + +func (r *fakeServerRepo) IDs(_ context.Context) ([]int, error) { + return nil, nil +} + +// fakeCommand is a deterministic GameServerCommand stub. +type fakeCommand struct { + output []byte + result int + execError error + // block is non-nil when the command should pause until the channel is + // closed (used by overlap/cancel tests to keep an execution in flight). + block <-chan struct{} +} + +func (c *fakeCommand) Execute(ctx context.Context, _ *domain.Server) error { + if c.block != nil { + select { + case <-c.block: + case <-ctx.Done(): + return ctx.Err() + } + } + return c.execError +} + +func (c *fakeCommand) Result() int { + if c.result == 0 { + return gameservercommands.SuccessResult + } + return c.result +} + +func (c *fakeCommand) IsComplete() bool { + return true +} + +func (c *fakeCommand) ReadOutput() []byte { + return c.output +} + +// fakeLoader returns the same fakeCommand for every load, recording call count. +type fakeLoader struct { + mu sync.Mutex + cmd *fakeCommand + calls int +} + +func (l *fakeLoader) LoadServerCommand(_ domain.ServerCommand, _ *domain.Server) contracts.GameServerCommand { + l.mu.Lock() + defer l.mu.Unlock() + + l.calls++ + return l.cmd +} + +func (l *fakeLoader) Calls() int { + l.mu.Lock() + defer l.mu.Unlock() + + return l.calls +} + +func newServerForTask(id int) *domain.Server { + return domain.NewServer( + id, + true, + domain.ServerInstalled, + false, + "server-"+strconv.Itoa(id), + "uuid-"+strconv.Itoa(id), + "short-"+strconv.Itoa(id), + domain.Game{}, + domain.GameMod{}, + "127.0.0.1", + 25565, 25565, 25565, + "", + "/srv/test/"+strconv.Itoa(id), + "", + "", "", "", "", + false, + time.Unix(0, 0), + map[string]string{}, + domain.Settings{}, + time.Unix(0, 0), + 0, 0, + ) +} + +func newTestScheduler(loader CommandLoader, repo domain.ServerRepository, sender ServerTaskSender) *Scheduler { + return NewScheduler(nil, loader, repo, sender) +} + +func freezeTime(s *Scheduler, now time.Time) { + s.nowFn = func() time.Time { return now } +} diff --git a/internal/app/servers_scheduler/output.go b/internal/app/servers_scheduler/output.go new file mode 100644 index 0000000..c2bb77a --- /dev/null +++ b/internal/app/servers_scheduler/output.go @@ -0,0 +1,38 @@ +package serversscheduler + +const ( + outputInlineMax = 64 * 1024 + outputChunkSize = 32 * 1024 + errMessageMax = 4 * 1024 +) + +// splitOutput decides how the command output ships to the API. +// +// Small output (≤ 64 KB) is returned inline; the caller emits no +// ServerTaskExecutionLog chunks. Larger output is split into 32 KB +// chunks for streaming and the last 64 KB tail is also returned inline +// so the API has a quick-read snippet even when the full payload was +// streamed. +func splitOutput(buf []byte) ([][]byte, []byte, bool) { + if len(buf) <= outputInlineMax { + return nil, buf, false + } + + chunks := make([][]byte, 0, (len(buf)+outputChunkSize-1)/outputChunkSize) + for i := 0; i < len(buf); i += outputChunkSize { + end := i + outputChunkSize + if end > len(buf) { + end = len(buf) + } + chunks = append(chunks, buf[i:end]) + } + + return chunks, buf[len(buf)-outputInlineMax:], true +} + +func truncateError(s string) string { + if len(s) <= errMessageMax { + return s + } + return s[:errMessageMax] +} diff --git a/internal/app/servers_scheduler/output_test.go b/internal/app/servers_scheduler/output_test.go new file mode 100644 index 0000000..4c80b94 --- /dev/null +++ b/internal/app/servers_scheduler/output_test.go @@ -0,0 +1,83 @@ +package serversscheduler + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitOutput_SmallPayload_GoesInline(t *testing.T) { + buf := bytes.Repeat([]byte("a"), 1024) + + chunks, inline, streamed := splitOutput(buf) + + assert.Nil(t, chunks) + assert.Equal(t, buf, inline) + assert.False(t, streamed) +} + +func TestSplitOutput_ExactInlineMax_GoesInline(t *testing.T) { + buf := bytes.Repeat([]byte("a"), outputInlineMax) + + chunks, inline, streamed := splitOutput(buf) + + assert.Nil(t, chunks) + assert.Equal(t, buf, inline) + assert.False(t, streamed) +} + +func TestSplitOutput_JustOverInlineMax_StreamsAndTrims(t *testing.T) { + buf := bytes.Repeat([]byte("a"), outputInlineMax+1) + + chunks, inline, streamed := splitOutput(buf) + + assert.True(t, streamed) + assert.Len(t, inline, outputInlineMax, "inline must hold last 64KB") + totalChunked := 0 + for _, c := range chunks { + totalChunked += len(c) + } + assert.Equal(t, len(buf), totalChunked, "chunks must cover entire buffer") +} + +func TestSplitOutput_LargePayload_ChunksAreOrderedAndBounded(t *testing.T) { + buf := make([]byte, 200*1024) + for i := range buf { + buf[i] = byte(i % 256) + } + + chunks, inline, streamed := splitOutput(buf) + + assert.True(t, streamed) + assert.Equal(t, 7, len(chunks), "200KB / 32KB = 6 full + 1 partial = 7 chunks") + for i, c := range chunks { + if i == len(chunks)-1 { + assert.LessOrEqual(t, len(c), outputChunkSize) + } else { + assert.Equal(t, outputChunkSize, len(c)) + } + } + rebuilt := make([]byte, 0, len(buf)) + for _, c := range chunks { + rebuilt = append(rebuilt, c...) + } + assert.Equal(t, buf, rebuilt) + assert.Equal(t, buf[len(buf)-outputInlineMax:], inline) +} + +func TestTruncateError_LongMessage(t *testing.T) { + msg := string(bytes.Repeat([]byte("x"), errMessageMax+100)) + + got := truncateError(msg) + + assert.Len(t, got, errMessageMax) +} + +func TestTruncateError_ShortMessage_Unchanged(t *testing.T) { + msg := "boom" + + got := truncateError(msg) + + assert.Equal(t, msg, got) +} diff --git a/internal/app/servers_scheduler/policy.go b/internal/app/servers_scheduler/policy.go new file mode 100644 index 0000000..c24e477 --- /dev/null +++ b/internal/app/servers_scheduler/policy.go @@ -0,0 +1,144 @@ +package serversscheduler + +import ( + "time" + + "github.com/gameap/daemon/internal/app/domain" + pb "github.com/gameap/gameap/pkg/proto" +) + +// catchupGracePeriod is the threshold beyond which a task with a past +// executeDate is considered "late" and triggers the configured catchup +// policy. Tasks less than this grace period late simply fire on the next +// tick without policy intervention. +const catchupGracePeriod = time.Minute + +func mapProtoCommandToDomain(c pb.ServerTaskCommand) (domain.ServerCommand, bool) { + switch c { + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_START: + return domain.Start, true + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_STOP: + return domain.Stop, true + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_RESTART: + return domain.Restart, true + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_UPDATE: + return domain.Update, true + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_REINSTALL: + return domain.Reinstall, true + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_UNSPECIFIED: + return 0, false + default: + return 0, false + } +} + +func mapProtoCommandToTaskCommand(c pb.ServerTaskCommand) domain.ServerTaskCommand { + switch c { + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_START: + return domain.ServerTaskStart + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_STOP: + return domain.ServerTaskStop + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_RESTART: + return domain.ServerTaskRestart + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_UPDATE: + return domain.ServerTaskUpdate + case pb.ServerTaskCommand_SERVER_TASK_COMMAND_REINSTALL: + return domain.ServerTaskReinstall + } + return "" +} + +func mapProtoOverlapPolicy(p pb.ServerTaskOverlapPolicy) domain.ServerTaskOverlapPolicy { + switch p { + case pb.ServerTaskOverlapPolicy_SERVER_TASK_OVERLAP_POLICY_SKIP: + return domain.ServerTaskOverlapSkip + case pb.ServerTaskOverlapPolicy_SERVER_TASK_OVERLAP_POLICY_QUEUE: + return domain.ServerTaskOverlapQueue + } + return domain.ServerTaskOverlapSkip +} + +func mapProtoCatchupPolicy(p pb.ServerTaskCatchupPolicy) domain.ServerTaskCatchupPolicy { + switch p { + case pb.ServerTaskCatchupPolicy_SERVER_TASK_CATCHUP_POLICY_SKIP: + return domain.ServerTaskCatchupSkip + case pb.ServerTaskCatchupPolicy_SERVER_TASK_CATCHUP_POLICY_RUN_ONCE: + return domain.ServerTaskCatchupRunOnce + } + return domain.ServerTaskCatchupSkip +} + +func protoToTaskOptions(t *pb.ServerTask, server *domain.Server) domain.ServerTaskOptions { + opts := domain.ServerTaskOptions{ + ID: t.GetId(), + ServerID: t.GetServerId(), + NodeID: t.GetNodeId(), + Version: t.GetVersion(), + Command: mapProtoCommandToTaskCommand(t.GetCommand()), + Server: server, + Repeat: int(t.GetRepeatCount()), + RepeatPeriod: t.GetRepeatPeriod().AsDuration(), + Counter: int(t.GetCounter()), + OverlapPolicy: mapProtoOverlapPolicy(t.GetOverlapPolicy()), + CatchupPolicy: mapProtoCatchupPolicy(t.GetCatchupPolicy()), + Name: t.GetName(), + Timezone: t.GetTimezone(), + Payload: t.GetPayload(), + Enabled: t.GetEnabled(), + } + + if ts := t.GetExecuteDate(); ts != nil { + opts.ExecuteDate = ts.AsTime() + } + if ts := t.GetUpdatedAt(); ts != nil { + opts.UpdatedAt = ts.AsTime() + } + + return opts +} + +// nextFireAfter returns the next executeDate for a task whose original +// executeDate is in the past. SKIP jumps forward in whole periods to the +// first slot >= now; RUN_ONCE returns now (caller is responsible for +// recomputing the cadence after the one catchup run). +func nextFireAfter( + executeDate time.Time, + period time.Duration, + policy domain.ServerTaskCatchupPolicy, + now time.Time, +) time.Time { + if !executeDate.Before(now) { + return executeDate + } + if period <= 0 { + if policy == domain.ServerTaskCatchupRunOnce { + return now + } + return time.Time{} + } + if policy == domain.ServerTaskCatchupRunOnce { + return now + } + elapsed := now.Sub(executeDate) + skips := int64(elapsed/period) + 1 + return executeDate.Add(time.Duration(skips) * period) +} + +// applyCatchupOnApply mutates the task's executeDate when it arrives from +// a snapshot or delta late enough to trip the configured catchup policy. +// Caller must hold no scheduler locks; the task's own mutex is taken +// internally via SetExecuteDate. +func applyCatchupOnApply(t *domain.ServerTask, now time.Time) { + executeDate := t.ExecuteDate() + if !executeDate.Before(now.Add(-catchupGracePeriod)) { + return + } + if !t.Enabled() { + return + } + + next := nextFireAfter(executeDate, t.RepeatPeriod(), t.CatchupPolicy(), now) + if !next.IsZero() { + t.SetExecuteDate(next) + } +} diff --git a/internal/app/servers_scheduler/policy_test.go b/internal/app/servers_scheduler/policy_test.go new file mode 100644 index 0000000..e6021f4 --- /dev/null +++ b/internal/app/servers_scheduler/policy_test.go @@ -0,0 +1,106 @@ +package serversscheduler + +import ( + "testing" + "time" + + "github.com/gameap/daemon/internal/app/domain" + "github.com/stretchr/testify/assert" +) + +func TestNextFireAfter_FutureDate_ReturnsAsIs(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + future := now.Add(30 * time.Second) + + got := nextFireAfter(future, time.Minute, domain.ServerTaskCatchupSkip, now) + + assert.Equal(t, future, got) +} + +func TestNextFireAfter_SkipPolicy_JumpsForwardByWholePeriods(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + executeDate := now.Add(-25 * time.Minute) + + got := nextFireAfter(executeDate, 10*time.Minute, domain.ServerTaskCatchupSkip, now) + + assert.Equal(t, executeDate.Add(30*time.Minute), got) + assert.True(t, !got.Before(now), "skip catchup must produce a slot >= now") +} + +func TestNextFireAfter_RunOncePolicy_ReturnsNow(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + executeDate := now.Add(-2 * time.Hour) + + got := nextFireAfter(executeDate, 10*time.Minute, domain.ServerTaskCatchupRunOnce, now) + + assert.Equal(t, now, got) +} + +func TestNextFireAfter_NonRepeating_SkipPolicy_ReturnsZeroTime(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + executeDate := now.Add(-time.Hour) + + got := nextFireAfter(executeDate, 0, domain.ServerTaskCatchupSkip, now) + + assert.True(t, got.IsZero(), "non-repeating + SKIP catchup must yield zero time") +} + +func TestNextFireAfter_NonRepeating_RunOnce_ReturnsNow(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + executeDate := now.Add(-time.Hour) + + got := nextFireAfter(executeDate, 0, domain.ServerTaskCatchupRunOnce, now) + + assert.Equal(t, now, got) +} + +func TestApplyCatchupOnApply_OnlyLateTasksAreShifted(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + executeDate := now.Add(-30 * time.Second) + + task := domain.NewServerTask(domain.ServerTaskOptions{ + ID: 1, + ExecuteDate: executeDate, + RepeatPeriod: 10 * time.Minute, + CatchupPolicy: domain.ServerTaskCatchupSkip, + Enabled: true, + }) + + applyCatchupOnApply(task, now) + + assert.Equal(t, executeDate, task.ExecuteDate(), "task within grace period must be untouched") +} + +func TestApplyCatchupOnApply_LateSkipShiftsForward(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + executeDate := now.Add(-25 * time.Minute) + + task := domain.NewServerTask(domain.ServerTaskOptions{ + ID: 1, + ExecuteDate: executeDate, + RepeatPeriod: 10 * time.Minute, + CatchupPolicy: domain.ServerTaskCatchupSkip, + Enabled: true, + }) + + applyCatchupOnApply(task, now) + + assert.Equal(t, executeDate.Add(30*time.Minute), task.ExecuteDate()) +} + +func TestApplyCatchupOnApply_DisabledTask_NotShifted(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + executeDate := now.Add(-1 * time.Hour) + + task := domain.NewServerTask(domain.ServerTaskOptions{ + ID: 1, + ExecuteDate: executeDate, + RepeatPeriod: 10 * time.Minute, + CatchupPolicy: domain.ServerTaskCatchupSkip, + Enabled: false, + }) + + applyCatchupOnApply(task, now) + + assert.Equal(t, executeDate, task.ExecuteDate(), "disabled task must not be shifted") +} diff --git a/internal/app/servers_scheduler/queue.go b/internal/app/servers_scheduler/queue.go deleted file mode 100644 index 86b5548..0000000 --- a/internal/app/servers_scheduler/queue.go +++ /dev/null @@ -1,102 +0,0 @@ -package serversscheduler - -import ( - "sort" - "sync" - - "github.com/gameap/daemon/internal/app/domain" -) - -type taskQueue struct { - tasks []*domain.ServerTask - ids map[int]struct{} - mutex *sync.Mutex -} - -func newTaskQueue() *taskQueue { - return &taskQueue{ - tasks: make([]*domain.ServerTask, 0), - ids: make(map[int]struct{}), - mutex: &sync.Mutex{}, - } -} - -func (q *taskQueue) Exists(task *domain.ServerTask) bool { - q.mutex.Lock() - defer q.mutex.Unlock() - - _, exists := q.ids[task.ID()] - - return exists -} - -func (q *taskQueue) Replace(task *domain.ServerTask) { - q.mutex.Lock() - defer q.mutex.Unlock() - - for i, t := range q.tasks { - if t.ID() == task.ID() { - q.tasks = append(q.tasks[:i], q.tasks[i+1:]...) - - break - } - } - - q.insertSorted(task) -} - -func (q *taskQueue) Put(task *domain.ServerTask) { - q.mutex.Lock() - defer q.mutex.Unlock() - - if _, exists := q.ids[task.ID()]; exists { - return - } - - q.ids[task.ID()] = struct{}{} - q.insertSorted(task) -} - -// Pop returns the earliest task without removing it from the queue. -func (q *taskQueue) Pop() *domain.ServerTask { - q.mutex.Lock() - defer q.mutex.Unlock() - - if len(q.tasks) == 0 { - return nil - } - - return q.tasks[0] -} - -func (q *taskQueue) Remove(task *domain.ServerTask) { - q.mutex.Lock() - defer q.mutex.Unlock() - - for i, t := range q.tasks { - if t.ID() == task.ID() { - q.tasks = append(q.tasks[:i], q.tasks[i+1:]...) - delete(q.ids, task.ID()) - - return - } - } -} - -func (q *taskQueue) Empty() bool { - q.mutex.Lock() - defer q.mutex.Unlock() - - return len(q.tasks) == 0 -} - -func (q *taskQueue) insertSorted(task *domain.ServerTask) { - executeDate := task.ExecuteDate() - i := sort.Search(len(q.tasks), func(j int) bool { - return q.tasks[j].ExecuteDate().After(executeDate) - }) - - q.tasks = append(q.tasks, nil) - copy(q.tasks[i+1:], q.tasks[i:]) - q.tasks[i] = task -} diff --git a/internal/app/servers_scheduler/queue_test.go b/internal/app/servers_scheduler/queue_test.go deleted file mode 100644 index 2d37f7a..0000000 --- a/internal/app/servers_scheduler/queue_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package serversscheduler - -import ( - "testing" - "time" - - "github.com/gameap/daemon/internal/app/domain" - "github.com/stretchr/testify/assert" -) - -var firstTask = domain.NewServerTask( - 1, - domain.ServerTaskStart, - nil, - 1, - 1*time.Second, - 1, - time.Now().Add(10*time.Minute), -) - -var secondTask = domain.NewServerTask( - 2, - domain.ServerTaskStart, - nil, - 1, - 1*time.Second, - 5, - time.Now().Add(20*time.Minute), -) - -var thirdTask = domain.NewServerTask( - 3, - domain.ServerTaskStart, - nil, - 1, - 1*time.Second, - 1, - time.Now().Add(5*time.Hour), -) - -var fourthTask = domain.NewServerTask( - 4, - domain.ServerTaskStart, - nil, - 1, - 2*time.Second, - 1, - time.Now().Add(11*time.Hour), -) - -var fifthTask = domain.NewServerTask( - 4, - domain.ServerTaskStart, - nil, - 1, - 2*time.Second, - 1, - time.Now().Add(20*time.Hour), -) - -func TestPriorityQueue(t *testing.T) { - q := newTaskQueue() - q.Put(thirdTask) - q.Put(firstTask) - q.Put(fourthTask) - q.Put(secondTask) - q.Remove(fourthTask) - q.Put(fifthTask) - - task1 := q.Pop() - q.Remove(task1) - task2 := q.Pop() - q.Remove(task2) - task3 := q.Pop() - q.Remove(task3) - task5 := q.Pop() - q.Remove(task5) - - assert.Equal(t, firstTask, task1) - assert.Equal(t, secondTask, task2) - assert.Equal(t, thirdTask, task3) - assert.Equal(t, fifthTask, task5) -} - -func TestPriorityQueue_EmptyValue_ExpectNil(t *testing.T) { - q := newTaskQueue() - - task := q.Pop() - - assert.Nil(t, task) -} diff --git a/internal/app/servers_scheduler/resync.go b/internal/app/servers_scheduler/resync.go new file mode 100644 index 0000000..216d319 --- /dev/null +++ b/internal/app/servers_scheduler/resync.go @@ -0,0 +1,43 @@ +package serversscheduler + +import ( + "sync" + "time" + + pb "github.com/gameap/gameap/pkg/proto" +) + +const resyncMinInterval = 5 * time.Second + +type resyncTrigger struct { + mu sync.Mutex + lastSent time.Time + sender ServerTaskSender + nowFn func() time.Time +} + +func newResyncTrigger(sender ServerTaskSender, nowFn func() time.Time) *resyncTrigger { + return &resyncTrigger{sender: sender, nowFn: nowFn} +} + +// Trigger emits a ServerTaskResyncRequest, rate-limited to one emission +// per resyncMinInterval. v1 always sends LastKnownSnapshotVersion=0 — +// the API ignores it and always responds with a full snapshot. +func (r *resyncTrigger) Trigger() { + r.mu.Lock() + defer r.mu.Unlock() + + now := r.nowFn() + if !r.lastSent.IsZero() && now.Sub(r.lastSent) < resyncMinInterval { + return + } + r.lastSent = now + + r.sender.Send(&pb.DaemonMessage{ + Payload: &pb.DaemonMessage_ServerTaskResyncRequest{ + ServerTaskResyncRequest: &pb.ServerTaskResyncRequest{ + LastKnownSnapshotVersion: 0, + }, + }, + }) +} diff --git a/internal/app/servers_scheduler/scheduler.go b/internal/app/servers_scheduler/scheduler.go index bff6011..b65b0af 100644 --- a/internal/app/servers_scheduler/scheduler.go +++ b/internal/app/servers_scheduler/scheduler.go @@ -7,54 +7,57 @@ import ( "github.com/gameap/daemon/internal/app/config" "github.com/gameap/daemon/internal/app/domain" - gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" "github.com/gameap/daemon/pkg/logger" - "github.com/pkg/errors" + pb "github.com/gameap/gameap/pkg/proto" + "github.com/rs/xid" log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" ) -var updateTimeout = 5 * time.Second +const tickInterval = 5 * time.Second type Scheduler struct { - config *config.Config - repository domain.ServerTaskRepository - serverCommandFactory *gameservercommands.ServerCommandFactory + cfg *config.Config + commandLoader CommandLoader + serverRepo domain.ServerRepository + sender ServerTaskSender - // Runtime, state - mutex *sync.Mutex - lastUpdated time.Time - consecutiveFailures int - queue *taskQueue - grpcMode bool + cache *taskCache + resync *resyncTrigger + + mu sync.Mutex + inFlight map[uint64]*runningTask + byExecID map[string]*executionRecord + + nowFn func() time.Time } func NewScheduler( - config *config.Config, - repository domain.ServerTaskRepository, - serverCommandFactory *gameservercommands.ServerCommandFactory, + cfg *config.Config, + commandLoader CommandLoader, + serverRepo domain.ServerRepository, + sender ServerTaskSender, ) *Scheduler { - return &Scheduler{ - config: config, - repository: repository, - serverCommandFactory: serverCommandFactory, - mutex: &sync.Mutex{}, - queue: newTaskQueue(), + s := &Scheduler{ + cfg: cfg, + commandLoader: commandLoader, + serverRepo: serverRepo, + sender: sender, + cache: newTaskCache(), + inFlight: make(map[uint64]*runningTask), + byExecID: make(map[string]*executionRecord), + nowFn: time.Now, } + s.resync = newResyncTrigger(sender, s.now) + return s } -func (s *Scheduler) SetGRPCMode(enabled bool) { - s.grpcMode = enabled +func (s *Scheduler) now() time.Time { + return s.nowFn() } func (s *Scheduler) Run(ctx context.Context) error { - if !s.grpcMode { - err := s.updateTasksIfNeeded(ctx) - if err != nil { - logger.Logger(ctx).WithError(err).Warn("Failed to update game server tasks") - } - } - - ticker := time.NewTicker(updateTimeout) + ticker := time.NewTicker(tickInterval) defer ticker.Stop() for { @@ -62,137 +65,317 @@ func (s *Scheduler) Run(ctx context.Context) error { case <-ctx.Done(): return nil case <-ticker.C: - s.runNext(ctx) + s.tick(ctx) + } + } +} - if !s.grpcMode { - err := s.updateTasksIfNeeded(ctx) - if err != nil { - logger.Logger(ctx).WithError(err).Warn("Failed to update game server tasks") - } +func (s *Scheduler) tick(ctx context.Context) { + now := s.now() + + for _, task := range s.cache.Snapshot() { + if ctx.Err() != nil { + return + } + if !task.IsActive() { + continue + } + if task.ExecuteDate().After(now) { + continue + } + + server := task.Server() + if server == nil { + resolved, err := s.serverRepo.FindByID(ctx, int(task.ServerID())) + if err != nil || resolved == nil { + logger.Logger(ctx).WithError(err).WithFields(log.Fields{ + "task_id": task.ID(), + "server_id": task.ServerID(), + }).Warn("Skipping server task: server not in local cache") + continue } + server = resolved } + + s.fire(ctx, task, server) } } -func (s *Scheduler) runNext(ctx context.Context) { - task := s.queue.Pop() - if task == nil { - return +func (s *Scheduler) fire(ctx context.Context, task *domain.ServerTask, server *domain.Server) { + s.mu.Lock() + + rt, ok := s.inFlight[task.ID()] + if !ok { + rt = &runningTask{} + s.inFlight[task.ID()] = rt } - ctx = logger.WithLogger(ctx, logger.Logger(ctx).WithFields(log.Fields{ - "serverTaskID": task.ID(), - "gameServerID": task.Server().ID(), - })) + rec := &executionRecord{ + execID: xid.New().String(), + taskID: task.ID(), + taskVersion: task.Version(), + serverID: task.ServerID(), + nodeID: task.NodeID(), + command: domainCommandToProto(task.Command()), + payload: task.Payload(), + startedAt: s.now(), + } - if task.ExecuteDate().Before(time.Now()) { - s.queue.Remove(task) + if rt.current != nil { + switch task.OverlapPolicy() { + case domain.ServerTaskOverlapQueue: + rt.queued = append(rt.queued, rec) + s.byExecID[rec.execID] = rec + s.mu.Unlock() + task.IncreaseCountersAndTime() + return + default: + // SKIP (and unspecified) emits Started+Finished(SKIPPED) immediately. + s.byExecID[rec.execID] = rec + s.mu.Unlock() + task.IncreaseCountersAndTime() + s.sendStarted(rec) + s.sendFinishedSkipped(rec) + s.cleanupFinished(rec.execID) + return + } + } + + rt.current = rec + s.byExecID[rec.execID] = rec + s.mu.Unlock() + task.IncreaseCountersAndTime() + + go s.executeNow(ctx, rec, server) +} - if task.CanExecute() { - success := s.executeTask(ctx, task) - s.prolongTask(ctx, task, success) +func (s *Scheduler) ApplySnapshot(snap *pb.ServerTaskSnapshot) { + if snap == nil { + return + } + now := s.now() + + tasks := make([]*domain.ServerTask, 0, len(snap.GetTasks())) + for _, pt := range snap.GetTasks() { + opts := protoToTaskOptions(pt, s.resolveServer(pt.GetServerId())) + var t *domain.ServerTask + if cached := s.cache.Get(opts.ID); cached != nil { + cached.UpdateFromOptions(opts) + t = cached + } else { + t = domain.NewServerTask(opts) } + applyCatchupOnApply(t, now) + tasks = append(tasks, t) } + + s.cache.Replace(tasks) + + log.WithFields(log.Fields{ + "count": len(tasks), + "snapshot_version": snap.GetSnapshotVersion(), + }).Info("Server task snapshot applied") } -func (s *Scheduler) executeTask(ctx context.Context, task *domain.ServerTask) bool { - cmd := s.serverCommandFactory.LoadServerCommand(taskCommandToServerCommand(task.Command()), task.Server()) - if cmd == nil { - logger.Logger(ctx).Warn("Unknown server task command, skipping") +func (s *Scheduler) ApplyDelta(delta *pb.ServerTaskDelta) { + if delta == nil { + return + } - return false + if upserted := delta.GetUpserted(); upserted != nil { + s.applyUpsert(upserted) + return } - err := cmd.Execute(ctx, task.Server()) - if err != nil { - logger.Logger(ctx).WithError(err).Warn("Failed to execute server task") - s.saveFailInfo(ctx, task, err.Error()) - return false + if deleted := delta.GetDeleted(); deleted != nil { + s.applyDeleted(deleted) } +} - task.Server().NoticeTaskCompleted() +func (s *Scheduler) applyUpsert(pt *pb.ServerTask) { + cached := s.cache.Get(pt.GetId()) + switch { + case cached == nil && pt.GetVersion() > 1: + log.WithFields(log.Fields{ + "task_id": pt.GetId(), + "version": pt.GetVersion(), + }).Info("Unknown task with version > 1: requesting resync") + s.resync.Trigger() + case cached != nil && pt.GetVersion() <= cached.Version(): + log.WithFields(log.Fields{ + "task_id": pt.GetId(), + "delta_version": pt.GetVersion(), + "cached_version": cached.Version(), + }).Debug("Stale server task delta dropped") + return + case cached != nil && pt.GetVersion() > cached.Version()+1: + log.WithFields(log.Fields{ + "task_id": pt.GetId(), + "delta_version": pt.GetVersion(), + "cached_version": cached.Version(), + }).Info("Server task version gap: requesting resync") + s.resync.Trigger() + } - result := cmd.Result() - if result == gameservercommands.ErrorResult { - s.saveFailInfo(ctx, task, string(cmd.ReadOutput())) - return false + opts := protoToTaskOptions(pt, s.resolveServer(pt.GetServerId())) + var t *domain.ServerTask + if cached != nil { + cached.UpdateFromOptions(opts) + t = cached + } else { + t = domain.NewServerTask(opts) } + applyCatchupOnApply(t, s.now()) + s.cache.Put(t) +} - return true +func (s *Scheduler) applyDeleted(deleted *pb.ServerTaskDeleted) { + cached := s.cache.Get(deleted.GetId()) + if cached == nil { + return + } + if deleted.GetVersion() <= cached.Version() { + return + } + s.cache.Delete(deleted.GetId()) } -func (s *Scheduler) prolongTask(ctx context.Context, task *domain.ServerTask, success bool) { - if success { - task.IncreaseCountersAndTime() - } else { - task.ProlongTime() +func (s *Scheduler) CancelExecution(req *pb.ServerTaskExecutionCancel) { + if req == nil { + return + } + + s.mu.Lock() + rec, ok := s.byExecID[req.GetExecutionId()] + if !ok { + s.mu.Unlock() + s.sendStarted(&executionRecord{ + execID: req.GetExecutionId(), + taskID: req.GetTaskId(), + startedAt: s.now(), + }) + s.sendFinished(&executionRecord{ + execID: req.GetExecutionId(), + taskID: req.GetTaskId(), + startedAt: s.now(), + }, pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_CANCELED, req.GetReason(), nil, s.now()) + return } - if !s.grpcMode { - err := s.repository.Save(ctx, task) - if err != nil { - logger.Logger(ctx).WithError(err).Warn("Failed to prolong game server task") + rt := s.inFlight[rec.taskID] + if rt != nil && rt.current == rec { + s.mu.Unlock() + rec.cancel() + return + } + + if rt != nil { + for i, q := range rt.queued { + if q == rec { + rt.queued = append(rt.queued[:i], rt.queued[i+1:]...) + delete(s.byExecID, rec.execID) + s.mu.Unlock() + s.sendStarted(rec) + s.sendFinished(rec, + pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_CANCELED, + req.GetReason(), nil, s.now()) + return + } } } - s.queue.Put(task) + s.mu.Unlock() } -func (s *Scheduler) saveFailInfo(ctx context.Context, task *domain.ServerTask, errorText string) { - if s.grpcMode { +func (s *Scheduler) AckExecution(ack *pb.ServerTaskExecutionAck) { + if ack == nil { return } + log.WithField("execution_id", ack.GetExecutionId()).Debug("Server task execution ack received") +} - err := s.repository.Fail(ctx, task, []byte(errorText)) - if err != nil { - log.Error(err) +func (s *Scheduler) InFlightExecutions() []*pb.InFlightServerTaskExecution { + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]*pb.InFlightServerTaskExecution, 0) + for _, rt := range s.inFlight { + if rt.current == nil { + continue + } + out = append(out, &pb.InFlightServerTaskExecution{ + ExecutionId: rt.current.execID, + TaskId: rt.current.taskID, + StartedAt: timestamppb.New(rt.current.startedAt), + }) } + return out } -func (s *Scheduler) updateTasksIfNeeded(ctx context.Context) error { - s.mutex.Lock() - defer s.mutex.Unlock() - - backoff := updateTimeout * time.Duration(1<= n { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("expected %d Finished events; got %d", n, len(s.Finished())) +} + +// waitForStarted polls until n Started events appear. +func waitForStarted(t *testing.T, s *fakeSender, n int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if len(s.Started()) >= n { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("expected %d Started events; got %d", n, len(s.Started())) +} diff --git a/internal/app/servers_scheduler/scheduler_version_test.go b/internal/app/servers_scheduler/scheduler_version_test.go new file mode 100644 index 0000000..4e173ad --- /dev/null +++ b/internal/app/servers_scheduler/scheduler_version_test.go @@ -0,0 +1,172 @@ +package serversscheduler + +import ( + "testing" + "time" + + "github.com/gameap/daemon/internal/app/domain" + pb "github.com/gameap/gameap/pkg/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func seedCache(t *testing.T, s *Scheduler, id, version uint64) { + t.Helper() + server := newServerForTask(int(id)) + task := domain.NewServerTask(domain.ServerTaskOptions{ + ID: id, + ServerID: 42, + Version: version, + Command: domain.ServerTaskRestart, + ExecuteDate: s.now().Add(time.Hour), + RepeatPeriod: time.Hour, + Enabled: true, + Server: server, + }) + s.cache.Put(task) +} + +func TestApplyDelta_StaleVersion_Dropped_NoResync(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + sender := newFakeSender() + scheduler := newTestScheduler(&fakeLoader{cmd: &fakeCommand{}}, newFakeServerRepo(newServerForTask(42)), sender) + freezeTime(scheduler, now) + + seedCache(t, scheduler, 1, 5) + + scheduler.ApplyDelta(&pb.ServerTaskDelta{ + Kind: &pb.ServerTaskDelta_Upserted{ + Upserted: &pb.ServerTask{ + Id: 1, + ServerId: 42, + Version: 4, + Command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_RESTART, + ExecuteDate: timestamppb.New(now.Add(time.Hour)), + RepeatPeriod: durationpb.New(time.Hour), + Enabled: true, + }, + }, + }) + + assert.Empty(t, sender.ResyncRequests(), "stale delta must not trigger resync") + cached := scheduler.cache.Get(1) + require.NotNil(t, cached) + assert.Equal(t, uint64(5), cached.Version(), "stale delta must not overwrite cached state") +} + +func TestApplyDelta_VersionGap_TriggersResync(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + sender := newFakeSender() + scheduler := newTestScheduler(&fakeLoader{cmd: &fakeCommand{}}, newFakeServerRepo(newServerForTask(42)), sender) + freezeTime(scheduler, now) + + seedCache(t, scheduler, 1, 5) + + scheduler.ApplyDelta(&pb.ServerTaskDelta{ + Kind: &pb.ServerTaskDelta_Upserted{ + Upserted: &pb.ServerTask{ + Id: 1, + ServerId: 42, + Version: 8, + Command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_RESTART, + ExecuteDate: timestamppb.New(now.Add(time.Hour)), + RepeatPeriod: durationpb.New(time.Hour), + Enabled: true, + }, + }, + }) + + assert.Len(t, sender.ResyncRequests(), 1, "version gap must trigger resync") + cached := scheduler.cache.Get(1) + require.NotNil(t, cached) + assert.Equal(t, uint64(8), cached.Version(), "version-gap delta is still applied so the cache catches up") +} + +func TestApplyDelta_UnknownTaskWithVersionAboveOne_TriggersResync(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + sender := newFakeSender() + scheduler := newTestScheduler(&fakeLoader{cmd: &fakeCommand{}}, newFakeServerRepo(newServerForTask(42)), sender) + freezeTime(scheduler, now) + + scheduler.ApplyDelta(&pb.ServerTaskDelta{ + Kind: &pb.ServerTaskDelta_Upserted{ + Upserted: &pb.ServerTask{ + Id: 99, + ServerId: 42, + Version: 3, + Command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_RESTART, + ExecuteDate: timestamppb.New(now.Add(time.Hour)), + RepeatPeriod: durationpb.New(time.Hour), + Enabled: true, + }, + }, + }) + + assert.Len(t, sender.ResyncRequests(), 1) + require.NotNil(t, scheduler.cache.Get(99)) +} + +func TestApplyDelta_ResyncRateLimit(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + sender := newFakeSender() + scheduler := newTestScheduler(&fakeLoader{cmd: &fakeCommand{}}, newFakeServerRepo(newServerForTask(42)), sender) + freezeTime(scheduler, now) + + // Seed a cached task at a different id to ensure seedCache is exercised with + // non-default values (also gives ApplyDelta a known cache state for the gap path). + seedCache(t, scheduler, 200, 1) + + for i := 0; i < 5; i++ { + scheduler.ApplyDelta(&pb.ServerTaskDelta{ + Kind: &pb.ServerTaskDelta_Upserted{ + Upserted: &pb.ServerTask{ + Id: uint64(100 + i), + ServerId: 42, + Version: 3, + Command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_RESTART, + ExecuteDate: timestamppb.New(now.Add(time.Hour)), + RepeatPeriod: durationpb.New(time.Hour), + Enabled: true, + }, + }, + }) + } + + assert.Len(t, sender.ResyncRequests(), 1, "back-to-back gaps must coalesce to a single resync") +} + +func TestApplyDelta_Deleted_RemovesCached(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + sender := newFakeSender() + scheduler := newTestScheduler(&fakeLoader{cmd: &fakeCommand{}}, newFakeServerRepo(newServerForTask(42)), sender) + freezeTime(scheduler, now) + + seedCache(t, scheduler, 1, 5) + + scheduler.ApplyDelta(&pb.ServerTaskDelta{ + Kind: &pb.ServerTaskDelta_Deleted{ + Deleted: &pb.ServerTaskDeleted{Id: 1, Version: 6}, + }, + }) + + assert.Nil(t, scheduler.cache.Get(1)) +} + +func TestApplyDelta_DeletedStale_Ignored(t *testing.T) { + now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + sender := newFakeSender() + scheduler := newTestScheduler(&fakeLoader{cmd: &fakeCommand{}}, newFakeServerRepo(newServerForTask(42)), sender) + freezeTime(scheduler, now) + + seedCache(t, scheduler, 1, 5) + + scheduler.ApplyDelta(&pb.ServerTaskDelta{ + Kind: &pb.ServerTaskDelta_Deleted{ + Deleted: &pb.ServerTaskDeleted{Id: 1, Version: 4}, + }, + }) + + assert.NotNil(t, scheduler.cache.Get(1)) +} diff --git a/internal/app/servers_scheduler/types.go b/internal/app/servers_scheduler/types.go new file mode 100644 index 0000000..766d443 --- /dev/null +++ b/internal/app/servers_scheduler/types.go @@ -0,0 +1,35 @@ +package serversscheduler + +import ( + "context" + "time" + + "github.com/gameap/daemon/internal/app/contracts" + "github.com/gameap/daemon/internal/app/domain" + pb "github.com/gameap/gameap/pkg/proto" +) + +type ServerTaskSender interface { + Send(msg *pb.DaemonMessage) +} + +type CommandLoader interface { + LoadServerCommand(cmd domain.ServerCommand, server *domain.Server) contracts.GameServerCommand +} + +type executionRecord struct { + execID string + taskID uint64 + taskVersion uint64 + serverID uint64 + nodeID uint64 + command pb.ServerTaskCommand + payload string + startedAt time.Time + cancel context.CancelFunc +} + +type runningTask struct { + current *executionRecord + queued []*executionRecord +} diff --git a/internal/app/services/api.go b/internal/app/services/api.go deleted file mode 100644 index bdf1eb0..0000000 --- a/internal/app/services/api.go +++ /dev/null @@ -1,224 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/gameap/daemon/internal/app/config" - "github.com/gameap/daemon/internal/app/contracts" - "github.com/gameap/daemon/internal/app/domain" - "github.com/gameap/daemon/pkg/logger" - "github.com/go-resty/resty/v2" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - lock "github.com/viney-shih/go-lock" -) - -const maxRefreshCount = 1 - -var ( - errInvalidRequestMethod = errors.New("invalid request method") - errRefreshTokenActionIsLocked = errors.New("refresh token action is already locked") - errNoopAPICaller = errors.New("HTTP API is not available in gRPC mode") -) - -// NoopAPICaller is a stub APIRequestMaker used in gRPC mode where the HTTP API is not needed. -type NoopAPICaller struct{} - -func (n *NoopAPICaller) Request(_ context.Context, _ domain.APIRequest) (contracts.APIResponse, error) { - return nil, errNoopAPICaller -} - -type APIClient struct { - innerClient *resty.Client - cfg *config.Config - - // runtime - tokenMutex *lock.CASMutex - apiServerTime time.Time - token string -} - -func NewAPICaller(ctx context.Context, cfg *config.Config, client *resty.Client) (*APIClient, error) { - api := &APIClient{ - innerClient: client, - cfg: cfg, - tokenMutex: lock.NewCASMutex(), - } - - var err error - maxRetryDuration := 30 * time.Second - retryInterval := 2 * time.Second - startTime := time.Now() - - for { - err = api.refreshToken(ctx) - if err == nil { - break - } - - logger.Error(ctx, errors.WithMessage(err, "failed to refresh token, retrying")) - - if time.Since(startTime) >= maxRetryDuration { - break - } - - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(retryInterval): - } - } - - return api, err -} - -func (c *APIClient) Request(ctx context.Context, request domain.APIRequest) (contracts.APIResponse, error) { - return c.request(ctx, request, 0) -} - -//nolint:funlen -func (c *APIClient) request( - ctx context.Context, - request domain.APIRequest, - deep uint8, -) (contracts.APIResponse, error) { - restyRequest := c.innerClient.R() - - restyRequest.SetHeader("Content-Type", "application/json") - restyRequest.SetHeader("X-Auth-Token", c.token) - - if len(request.QueryParams) > 0 { - restyRequest.SetQueryParams(request.QueryParams) - } - - if len(request.PathParams) > 0 { - restyRequest.SetPathParams(request.PathParams) - } - - if len(request.Header) > 0 { - for key, values := range request.Header { - for _, v := range values { - restyRequest.SetHeader(key, v) - } - } - } - - if len(request.Body) > 0 { - restyRequest.SetBody(request.Body) - } - - restyRequest.SetContext(ctx) - - var err error - var response *resty.Response - - l := logger.Logger(ctx) - - l = l.WithField("method", request.Method) - - start := time.Now() - - switch request.Method { - case http.MethodGet: - response, err = restyRequest.Get(request.URL) - case http.MethodPost: - body, isBytes := restyRequest.Body.([]byte) - if isBytes { - l = l.WithField("body", string(body)) - } - - response, err = restyRequest.Post(request.URL) - case http.MethodPut: - body, isBytes := restyRequest.Body.([]byte) - if isBytes { - l = l.WithField("body", string(body)) - } - - response, err = restyRequest.Put(request.URL) - case http.MethodPatch: - body, isBytes := restyRequest.Body.([]byte) - if isBytes { - l = l.WithField("body", string(body)) - } - - response, err = restyRequest.Patch(request.URL) - default: - return nil, errInvalidRequestMethod - } - - statusCode := 0 - if response != nil { - statusCode = response.StatusCode() - } - - if err != nil { - l.WithFields(logrus.Fields{ - "requestURL": restyRequest.URL, - "responseStatus": statusCode, - "responseTime": time.Since(start), - }).Debug("api request") - - return nil, errors.WithMessage(err, "[APIClient.request] failed to perform request") - } else { - l.WithFields(logrus.Fields{ - "requestURL": restyRequest.URL, - "responseStatus": statusCode, - "responseTime": time.Since(start), - }).Trace("api request") - } - - if statusCode == http.StatusUnauthorized && deep < maxRefreshCount { - logger.Warn(ctx, "invalid token, refreshing token") - err = c.refreshToken(ctx) - if err != nil { - return nil, err - } - - return c.request(ctx, request, deep+1) - } - - return response, nil -} - -func (c *APIClient) refreshToken(ctx context.Context) error { - locked := c.tokenMutex.TryLockWithContext(ctx) - if !locked { - return errRefreshTokenActionIsLocked - } - defer c.tokenMutex.Unlock() - - request := c.innerClient.R() - - request.SetContext(ctx) - - request.SetHeader("Content-Type", "application/json") - request.SetHeader("Authorization", fmt.Sprintf("Bearer %s", c.cfg.APIKey)) - - response, err := request.Get("/gdaemon_api/get_token") - if err != nil { - return errors.WithMessage(err, "failed to get gdaemon API token") - } - - if response.IsError() { - return domain.NewErrInvalidResponseFromAPI(response.StatusCode(), response.Body()) - } - - message := struct { - Token string `json:"token"` - Timestamp int64 `json:"timestamp"` - }{} - - err = json.Unmarshal(response.Body(), &message) - if err != nil { - return errors.WithMessage(err, "failed to unmarshal API response") - } - - c.token = message.Token - c.apiServerTime = time.Unix(message.Timestamp, 0) - - return nil -} diff --git a/internal/app/services/runner.go b/internal/app/services/runner.go index bde661b..339f630 100644 --- a/internal/app/services/runner.go +++ b/internal/app/services/runner.go @@ -4,13 +4,10 @@ import ( "context" "github.com/gameap/daemon/internal/app/config" - "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/internal/app/domain" gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" gdaemonscheduler "github.com/gameap/daemon/internal/app/gdaemon_scheduler" grpcclient "github.com/gameap/daemon/internal/app/grpc" - "github.com/gameap/daemon/internal/app/repositories" - "github.com/gameap/daemon/internal/app/server" serversloop "github.com/gameap/daemon/internal/app/servers_loop" serversscheduler "github.com/gameap/daemon/internal/app/servers_scheduler" "github.com/gameap/daemon/pkg/logger" @@ -21,37 +18,32 @@ import ( type Runner struct { cfg *config.Config - executor contracts.Executor - commandFactory *gameservercommands.ServerCommandFactory - apiClient contracts.APIRequestMaker - gdTaskManager *gdaemonscheduler.TaskManager - serverRepository domain.ServerRepository - serverTaskRepository domain.ServerTaskRepository - connectionManager *grpcclient.ConnectionManager - statusReporter *grpcclient.ServerStatusReporter - grpcMode bool + commandFactory *gameservercommands.ServerCommandFactory + gdTaskManager *gdaemonscheduler.TaskManager + serverRepository domain.ServerRepository + serversScheduler *serversscheduler.Scheduler + connectionManager *grpcclient.ConnectionManager + statusReporter *grpcclient.ServerStatusReporter } func NewProcessRunner( cfg *config.Config, - executor contracts.Executor, commandFactory *gameservercommands.ServerCommandFactory, - apiClient contracts.APIRequestMaker, gdTaskManager *gdaemonscheduler.TaskManager, serverRepository domain.ServerRepository, - serverTaskRepository domain.ServerTaskRepository, ) (*Runner, error) { return &Runner{ - cfg: cfg, - executor: executor, - commandFactory: commandFactory, - apiClient: apiClient, - gdTaskManager: gdTaskManager, - serverRepository: serverRepository, - serverTaskRepository: serverTaskRepository, + cfg: cfg, + commandFactory: commandFactory, + gdTaskManager: gdTaskManager, + serverRepository: serverRepository, }, nil } +func (r *Runner) SetServersScheduler(scheduler *serversscheduler.Scheduler) { + r.serversScheduler = scheduler +} + func (r *Runner) SetGRPCComponents( connectionManager *grpcclient.ConnectionManager, statusReporter *grpcclient.ServerStatusReporter, @@ -60,24 +52,7 @@ func (r *Runner) SetGRPCComponents( r.statusReporter = statusReporter } -func (r *Runner) EnableGRPCMode() { - r.grpcMode = true - - r.gdTaskManager.SetGRPCMode(true) - - if repo, ok := r.serverRepository.(*repositories.ServerRepository); ok { - repo.SetGRPCMode(true) - } -} - -func (r *Runner) Init(ctx context.Context, cfg *config.Config) error { - if !cfg.GRPC.Enabled { - err := r.initNodeConfigFromAPI(ctx, cfg) - if err != nil { - return err - } - } - +func (r *Runner) Init(_ context.Context, cfg *config.Config) error { config.InitDefaultScripts(cfg) if err := config.UpdateEnvPath(cfg); err != nil { @@ -87,51 +62,6 @@ func (r *Runner) Init(ctx context.Context, cfg *config.Config) error { return nil } -func (r *Runner) initNodeConfigFromAPI(ctx context.Context, cfg *config.Config) error { - cfgInitializer := config.NewNodeConfigInitializer(r.apiClient) - - return cfgInitializer.Initialize(ctx, cfg) -} - -func (r *Runner) RunGDaemonServer(ctx context.Context, cfg *config.Config) func() error { - return func() error { - certPEM, err := cfg.CertificateChainPEM() - if err != nil { - return errors.Wrap(err, "failed to read certificate chain") - } - - keyPEM, err := cfg.PrivateKeyPEM() - if err != nil { - return errors.Wrap(err, "failed to read private key") - } - - srv, err := server.NewServer( - cfg.ListenIP, - cfg.ListenPort, - cfg.WorkPath, - certPEM, - keyPEM, - server.CredentialsConfig{ - PasswordAuthentication: cfg.PasswordAuthentication, - Login: cfg.DaemonLogin, - Password: cfg.DaemonPassword, - }, - r.executor, - r.gdTaskManager, - ) - if err != nil { - return err - } - - ctx = logger.WithLogger(ctx, logger.WithFields(ctx, log.Fields{ - "service": "gameap daemon server", - })) - - log.Trace("Running gameap damon server...") - return runService(ctx, srv.Run) - } -} - func (r *Runner) RunGDaemonTaskScheduler(ctx context.Context, _ *config.Config) func() error { return func() error { ctx = logger.WithLogger(ctx, logger.Logger(ctx).WithFields(log.Fields{ @@ -143,29 +73,10 @@ func (r *Runner) RunGDaemonTaskScheduler(ctx context.Context, _ *config.Config) } } -func (r *Runner) RunServersLoop(ctx context.Context, cfg *config.Config) func() error { +func (r *Runner) RunServerScheduler(ctx context.Context, _ *config.Config) func() error { return func() error { - loop := serversloop.NewServersLoop(r.serverRepository, r.commandFactory, cfg) - - ctx = logger.WithLogger(ctx, logger.Logger(ctx).WithFields(log.Fields{ - "service": "servers loop", - })) - - log.Trace("Running server loop...") - return runService(ctx, loop.Run) - } -} - -func (r *Runner) RunServerScheduler(ctx context.Context, cfg *config.Config) func() error { - return func() error { - scheduler := serversscheduler.NewScheduler( - cfg, - r.serverTaskRepository, - r.commandFactory, - ) - - if r.grpcMode { - scheduler.SetGRPCMode(true) + if r.serversScheduler == nil { + return errors.New("servers scheduler not wired") } ctx = logger.WithLogger(ctx, logger.Logger(ctx).WithFields(log.Fields{ @@ -173,16 +84,12 @@ func (r *Runner) RunServerScheduler(ctx context.Context, cfg *config.Config) fun })) log.Trace("Running server tasks scheduler...") - return runService(ctx, scheduler.Run) + return runService(ctx, r.serversScheduler.Run) } } -func (r *Runner) RunGRPCClient(ctx context.Context, cfg *config.Config) func() error { +func (r *Runner) RunGRPCClient(ctx context.Context, _ *config.Config) func() error { return func() error { - if !cfg.GRPC.Enabled { - return nil - } - if r.connectionManager == nil { return errors.New("gRPC connection manager not initialized") } @@ -196,7 +103,7 @@ func (r *Runner) RunGRPCClient(ctx context.Context, cfg *config.Config) func() e } } -func (r *Runner) RunServersLoopWithReporter(ctx context.Context, cfg *config.Config) func() error { +func (r *Runner) RunServersLoop(ctx context.Context, cfg *config.Config) func() error { return func() error { loop := serversloop.NewServersLoop(r.serverRepository, r.commandFactory, cfg) diff --git a/internal/processmanager/docker.go b/internal/processmanager/docker.go index 458150b..478f90b 100644 --- a/internal/processmanager/docker.go +++ b/internal/processmanager/docker.go @@ -22,7 +22,6 @@ import ( "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/internal/app/domain" "github.com/gameap/daemon/pkg/logger" - "github.com/gameap/daemon/pkg/shellquote" "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/mount" @@ -731,11 +730,16 @@ func addPortBinding(portBindings network.PortMap, exposedPorts network.PortSet, } func (pm *Docker) parseCommand(server *domain.Server) ([]string, error) { - cmd := domain.ReplaceShortCodes(server.StartCommand(), pm.cfg, server) - if cmd == "" { + args, err := domain.BuildCommandArgs(pm.cfg, server, pm.cfg.Scripts.Start, server.StartCommand()) + if err != nil { + return nil, err + } + + if len(args) == 0 { return nil, ErrEmptyCommand } - return shellquote.Split(cmd) + + return args, nil } func (pm *Docker) containerName(server *domain.Server) string { diff --git a/internal/processmanager/podman.go b/internal/processmanager/podman.go index b2ed34d..d0b8231 100644 --- a/internal/processmanager/podman.go +++ b/internal/processmanager/podman.go @@ -20,7 +20,6 @@ import ( "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/internal/app/domain" "github.com/gameap/daemon/pkg/logger" - "github.com/gameap/daemon/pkg/shellquote" "github.com/pkg/errors" ) @@ -435,7 +434,9 @@ func (pm *Podman) SendInput( "AttachStdout": false, "AttachStderr": false, "Tty": false, - "Cmd": []string{"/bin/sh", "-c", fmt.Sprintf("echo %q", input)}, + // Pass the input as a positional parameter ($1) instead of interpolating + // it into the script, so the shell never re-parses user-controlled text. + "Cmd": []string{"/bin/sh", "-c", `echo "$1"`, "sh", input}, } path := fmt.Sprintf("/containers/%s/exec", containerName) @@ -763,11 +764,16 @@ func (pm *Podman) imageExists(ctx context.Context, imageName string) bool { } func (pm *Podman) parseCommand(server *domain.Server) ([]string, error) { - cmd := domain.ReplaceShortCodes(server.StartCommand(), pm.cfg, server) - if cmd == "" { + args, err := domain.BuildCommandArgs(pm.cfg, server, pm.cfg.Scripts.Start, server.StartCommand()) + if err != nil { + return nil, err + } + + if len(args) == 0 { return nil, ErrEmptyCommand } - return shellquote.Split(cmd) + + return args, nil } func (pm *Podman) containerName(server *domain.Server) string { diff --git a/internal/processmanager/shawl_windows.go b/internal/processmanager/shawl_windows.go index 0fadacf..d56b5d9 100644 --- a/internal/processmanager/shawl_windows.go +++ b/internal/processmanager/shawl_windows.go @@ -401,12 +401,13 @@ func (pm *Shawl) makeService(ctx context.Context, server *domain.Server, out io. return false, errors.WithMessage(err, "failed to build shawl arguments") } - binPath := fmt.Sprintf("%s %s", shellquote.WindowsArgToString(shawlPath), strings.Join(shawlArgs, " ")) + binPath := shellquote.WindowsArgToString(shawlPath) + " " + shellquote.WindowsJoin(shawlArgs...) - // Create the service using sc create - // Note: sc.exe requires binPath= to be followed by the value WITHOUT space, - // and the entire value must be quoted if it contains spaces - var scArgs string + // Build the `sc create` argument vector directly. Passing a vector keeps + // sc.exe's own arguments quoted by the OS exec layer (so obj= and the binPath + // value — which carries the whole shawl+game command line — stay intact), and + // avoids the string executor re-tokenizing and doubling backslashes. + var scArgs []string if pm.cfg.UseNetworkServiceUser { // Grant Modify permissions to NETWORK SERVICE for the server working directory workDir := server.WorkDir(pm.cfg) @@ -422,12 +423,12 @@ func (pm *Shawl) makeService(ctx context.Context, server *domain.Server, out io. _, _ = out.Write([]byte("Using service account: " + accountName + "\n")) // The NETWORK SERVICE account has no password - scArgs = fmt.Sprintf( - "sc create %s start=auto obj=%s binPath=%s", - serviceName, - shellquote.WindowsArgToString(accountName), - shellquote.WindowsArgToString(binPath), - ) + scArgs = []string{ + "sc", "create", serviceName, + "start=auto", + "obj=" + accountName, + "binPath=" + binPath, + } } else { // Get user credentials from config rawPw, exists := pm.cfg.Users[server.User()] @@ -450,21 +451,25 @@ func (pm *Shawl) makeService(ctx context.Context, server *domain.Server, out io. password = rawPw } - scArgs = fmt.Sprintf( - "sc create %s start=auto obj=%s password=%s binPath=%s", - serviceName, - server.User(), - shellquote.WindowsArgToString(password), - shellquote.WindowsArgToString(binPath), - ) + scArgs = []string{ + "sc", "create", serviceName, + "start=auto", + "obj=" + server.User(), + "password=" + password, + "binPath=" + binPath, + } } _, _ = out.Write([]byte("Creating service " + serviceName + "\n")) - _, _ = out.Write([]byte("Service configuration:\n")) - _, _ = out.Write([]byte(serviceConfig)) - _, _ = out.Write([]byte("binPath: " + binPath + "\n")) + _, _ = out.Write([]byte("Service executable: " + shawlPath + "\n")) - result, err := pm.executor.ExecWithWriter( + // The service config and binPath embed the whole game command line, which may + // carry credentials passed as arguments. Task output is streamed to the panel, + // so both stay in the local daemon log only. + logger.Debug(ctx, "Service configuration: "+serviceConfig) + logger.Debug(ctx, "Service binPath: "+binPath) + + result, err := pm.executor.ExecWithWriterArgs( ctx, scArgs, out, @@ -537,20 +542,18 @@ func (pm *Shawl) buildServiceConfig(server *domain.Server) (string, error) { func (pm *Shawl) buildShawlArgs(server *domain.Server) ([]string, error) { serviceName := pm.serviceName(server) - cmd := domain.MakeFullCommand( + cmdArr, err := domain.BuildCommandArgs( pm.cfg, server, pm.cfg.Scripts.Start, server.StartCommand(), ) - - if cmd == "" { - return nil, ErrEmptyCommand + if err != nil { + return nil, errors.WithMessage(err, "failed to build command") } - cmdArr, err := shellquote.Split(cmd) - if err != nil { - return nil, errors.WithMessage(err, "failed to split command") + if len(cmdArr) == 0 { + return nil, ErrEmptyCommand } executable := cmdArr[0] diff --git a/internal/processmanager/simple.go b/internal/processmanager/simple.go index 60c0c99..95e2a61 100644 --- a/internal/processmanager/simple.go +++ b/internal/processmanager/simple.go @@ -41,80 +41,62 @@ func (pm *Simple) Uninstall(_ context.Context, _ *domain.Server, _ io.Writer) (d func (pm *Simple) Start( ctx context.Context, server *domain.Server, out io.Writer, ) (domain.Result, error) { - return pm.execCommand( - ctx, - server, - domain.MakeFullCommand(pm.cfg, server, pm.cfg.Scripts.Start, server.StartCommand()), - out, - ) + return pm.execCommand(ctx, server, pm.cfg.Scripts.Start, server.StartCommand(), out) } func (pm *Simple) Stop( ctx context.Context, server *domain.Server, out io.Writer, ) (domain.Result, error) { - return pm.execCommand( - ctx, - server, - domain.MakeFullCommand(pm.cfg, server, pm.cfg.Scripts.Stop, server.StopCommand()), - out, - ) + return pm.execCommand(ctx, server, pm.cfg.Scripts.Stop, server.StopCommand(), out) } func (pm *Simple) Restart( ctx context.Context, server *domain.Server, out io.Writer, ) (domain.Result, error) { - return pm.execCommand( - ctx, - server, - domain.MakeFullCommand(pm.cfg, server, pm.cfg.Scripts.Restart, server.RestartCommand()), - out, - ) + return pm.execCommand(ctx, server, pm.cfg.Scripts.Restart, server.RestartCommand(), out) } func (pm *Simple) Status( ctx context.Context, server *domain.Server, out io.Writer, ) (domain.Result, error) { - return pm.execCommand( - ctx, - server, - domain.MakeFullCommand(pm.cfg, server, pm.cfg.Scripts.Status, ""), - out, - ) + return pm.execCommand(ctx, server, pm.cfg.Scripts.Status, "", out) } func (pm *Simple) GetOutput( ctx context.Context, server *domain.Server, out io.Writer, ) (domain.Result, error) { - result, err := pm.executor.ExecWithWriter( - ctx, - domain.MakeFullCommand(pm.cfg, server, pm.cfg.Scripts.GetConsole, ""), - out, - pm.executeOptions(server), - ) - if err != nil { - return domain.ErrorResult, errors.WithMessage(err, "failed to exec command") - } - - return domain.Result(result), nil + // The console is read with the plain executor: the detailed one prefixes the + // command line and appends the exit code, which would pollute the output. + return pm.execCommandWith(ctx, pm.executor, server, pm.cfg.Scripts.GetConsole, "", out) } func (pm *Simple) SendInput( ctx context.Context, input string, server *domain.Server, out io.Writer, ) (domain.Result, error) { - return pm.execCommand( - ctx, - server, - domain.MakeFullCommand(pm.cfg, server, pm.cfg.Scripts.SendCommand, input), - out, - ) + return pm.execCommand(ctx, server, pm.cfg.Scripts.SendCommand, input, out) } func (pm *Simple) execCommand( - ctx context.Context, server *domain.Server, command string, out io.Writer, + ctx context.Context, server *domain.Server, wrapper, serverCommand string, out io.Writer, +) (domain.Result, error) { + return pm.execCommandWith(ctx, pm.detailedExecutor, server, wrapper, serverCommand, out) +} + +func (pm *Simple) execCommandWith( + ctx context.Context, + executor contracts.Executor, + server *domain.Server, + wrapper, serverCommand string, + out io.Writer, ) (domain.Result, error) { - result, err := pm.detailedExecutor.ExecWithWriter( + args, err := domain.BuildCommandArgs(pm.cfg, server, wrapper, serverCommand) + if err != nil { + return domain.ErrorResult, errors.WithMessage(err, "failed to build command") + } + + result, err := executor.ExecWithWriterArgs( ctx, - command, + args, out, pm.executeOptions(server), ) diff --git a/internal/processmanager/systemd.go b/internal/processmanager/systemd.go index bc7c394..b2239f4 100644 --- a/internal/processmanager/systemd.go +++ b/internal/processmanager/systemd.go @@ -19,7 +19,6 @@ import ( "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/internal/app/domain" "github.com/gameap/daemon/pkg/logger" - "github.com/gameap/daemon/pkg/shellquote" "github.com/pkg/errors" "golang.org/x/sync/errgroup" ) @@ -700,23 +699,25 @@ func (pm *SystemD) installTarget() string { } func (pm *SystemD) makeStartCommand(server *domain.Server) (string, error) { - startCMD := domain.ReplaceShortCodes(server.StartCommand(), pm.cfg, server) - - if startCMD == "" { - return "", ErrEmptyCommand + args, err := domain.BuildCommandArgs(pm.cfg, server, pm.cfg.Scripts.Start, server.StartCommand()) + if err != nil { + return "", errors.WithMessage(err, "failed to build command") } - parts, err := shellquote.Split(startCMD) - if err != nil { - return "", errors.WithMessage(err, "failed to split command") + if len(args) == 0 { + return "", ErrEmptyCommand } - cmd := parts[0] - args := parts[1:] + cmd := args[0] var foundPath string - if !filepath.IsAbs(cmd) { + if filepath.IsAbs(cmd) { + foundPath, err = exec.LookPath(cmd) + if err != nil { + return "", errors.WithMessagef(err, "failed to find command '%s'", cmd) + } + } else { foundPath, err = exec.LookPath(filepath.Join(server.WorkDir(pm.cfg), cmd)) if err != nil { foundPath, err = exec.LookPath(cmd) @@ -726,21 +727,55 @@ func (pm *SystemD) makeStartCommand(server *domain.Server) (string, error) { } } - if filepath.IsAbs(cmd) { - foundPath, err = exec.LookPath(cmd) - if err != nil { - return "", errors.WithMessagef(err, "failed to find command '%s'", cmd) - } + args[0] = foundPath + + return systemdQuoteArgs(args), nil +} + +// systemdQuoteArgs serializes an argument vector for a systemd ExecStart= line. +// systemd's parser is not a POSIX shell: it performs environment ("$", "${}") +// and specifier ("%") expansion even inside quotes, so those are escaped as "$$" +// and "%%", and each argument is double-quoted when it contains whitespace or +// quoting characters so it stays a single argument. +func systemdQuoteArgs(args []string) string { + quoted := make([]string, len(args)) + for i, arg := range args { + quoted[i] = systemdQuoteArg(arg) } - startCommand := shellquote.Join(append([]string{foundPath}, args...)...) + return strings.Join(quoted, " ") +} + +func systemdQuoteArg(arg string) string { + arg = strings.NewReplacer("%", "%%", "$", "$$").Replace(arg) - result := domain.MakeFullCommand(pm.cfg, server, pm.cfg.Scripts.Start, startCommand) - if result == "" { - return "", ErrEmptyCommand + if arg == "" { + return `""` + } + + if !strings.ContainsAny(arg, " \t\n'\"\\") { + return arg } - return result, nil + var b strings.Builder + b.Grow(len(arg) + 2) + b.WriteByte('"') + + for i := 0; i < len(arg); i++ { + switch arg[i] { + case '"', '\\': + b.WriteByte('\\') + b.WriteByte(arg[i]) + case '\n': + b.WriteString(`\n`) + default: + b.WriteByte(arg[i]) + } + } + + b.WriteByte('"') + + return b.String() } func (pm *SystemD) makeSocket(ctx context.Context, server *domain.Server) error { @@ -1109,6 +1144,8 @@ func escapeSystemdEnv(key, value string) string { sb.WriteString("\\n") case '\t': sb.WriteString("\\t") + case '%': + sb.WriteString("%%") default: sb.WriteRune(r) } diff --git a/internal/processmanager/systemd_internal_test.go b/internal/processmanager/systemd_internal_test.go index b2edb78..924f735 100644 --- a/internal/processmanager/systemd_internal_test.go +++ b/internal/processmanager/systemd_internal_test.go @@ -75,7 +75,7 @@ func Test_makeCommand(t *testing.T) { return makeServerWithStartCommandAndDir("./start.sh 'some quotes' \"some quotes\" args", tempDir) }, - expectedCommand: filepath.Join(tempDir, "./start.sh 'some quotes' 'some quotes' args"), + expectedCommand: filepath.Join(tempDir, `./start.sh "some quotes" "some quotes" args`), }, { name: "success with global file", diff --git a/internal/processmanager/systemd_metrics_test.go b/internal/processmanager/systemd_metrics_test.go index c65a9cb..ee8bc2d 100644 --- a/internal/processmanager/systemd_metrics_test.go +++ b/internal/processmanager/systemd_metrics_test.go @@ -37,6 +37,16 @@ func (f *fakeExecutor) ExecWithWriter(_ context.Context, _ string, _ io.Writer, return 0, nil } +func (f *fakeExecutor) ExecArgs(_ context.Context, _ []string, _ contracts.ExecutorOptions) ([]byte, int, error) { + f.calls++ + + return f.output, f.code, f.err +} + +func (f *fakeExecutor) ExecWithWriterArgs(_ context.Context, _ []string, _ io.Writer, _ contracts.ExecutorOptions) (int, error) { + return 0, nil +} + func ptrUint64(v uint64) *uint64 { return &v } func TestParseSystemctlShow_HappyPath(t *testing.T) { diff --git a/internal/processmanager/systemd_quoting_test.go b/internal/processmanager/systemd_quoting_test.go new file mode 100644 index 0000000..d8f091a --- /dev/null +++ b/internal/processmanager/systemd_quoting_test.go @@ -0,0 +1,39 @@ +//go:build linux + +package processmanager + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSystemdQuoteArg(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"plain", "hlds_run", "hlds_run"}, + {"empty", "", `""`}, + {"space", "Andrey Server", `"Andrey Server"`}, + {"single_quote_and_space", "Andrey's Server", `"Andrey's Server"`}, + {"double_quote", `a"b`, `"a\"b"`}, + {"backslash", `a\b`, `"a\\b"`}, + {"dollar", "$HOME", "$$HOME"}, + {"percent", "100%", "100%%"}, + {"dollar_with_space", "$x y", `"$$x y"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, systemdQuoteArg(tt.input)) + }) + } +} + +func TestSystemdQuoteArgs(t *testing.T) { + got := systemdQuoteArgs([]string{"/usr/bin/srv", "--name", "Andrey's Server", "--pct", "100%"}) + + assert.Equal(t, `/usr/bin/srv --name "Andrey's Server" --pct 100%%`, got) +} diff --git a/internal/processmanager/tmux.go b/internal/processmanager/tmux.go index 0f4933e..5bb9ff5 100644 --- a/internal/processmanager/tmux.go +++ b/internal/processmanager/tmux.go @@ -10,13 +10,13 @@ import ( "os" "os/user" "strconv" - "strings" "time" "github.com/gameap/daemon/internal/app/config" "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/internal/app/domain" "github.com/gameap/daemon/pkg/logger" + "github.com/gameap/daemon/pkg/shellquote" "github.com/pkg/errors" "golang.org/x/sync/errgroup" ) @@ -53,9 +53,15 @@ func (pm *Tmux) Uninstall(_ context.Context, _ *domain.Server, _ io.Writer) (dom func (pm *Tmux) Start( ctx context.Context, server *domain.Server, out io.Writer, ) (domain.Result, error) { - startCmd := domain.MakeFullCommand(pm.cfg, server, pm.cfg.Scripts.Start, server.StartCommand()) + args, err := domain.BuildCommandArgs(pm.cfg, server, pm.cfg.Scripts.Start, server.StartCommand()) + if err != nil { + return domain.ErrorResult, errors.WithMessage(err, "failed to build command") + } - startCmd = strconv.Quote(strings.ReplaceAll(startCmd, `\"`, `"`)) + // tmux runs the session command through a shell, so serialize the argument + // vector with POSIX quoting: the shell parses it back into exactly these + // arguments, keeping every value a single argument. + startCmd := shellquote.Join(args...) options, err := pm.executeOptions(server) if err != nil { @@ -76,9 +82,14 @@ func (pm *Tmux) Start( ) } - result, err := pm.detailedExecutor.ExecWithWriter( + result, err := pm.detailedExecutor.ExecWithWriterArgs( ctx, - fmt.Sprintf(`tmux new-session -d -s %s -x %d %s`, sessionName, defaultWidth, startCmd), + []string{ + "tmux", "new-session", "-d", + "-s", sessionName, + "-x", strconv.Itoa(defaultWidth), + startCmd, + }, out, options, ) @@ -197,11 +208,9 @@ func (pm *Tmux) SendInput( sessionName := pm.resolveSessionName(ctx, server, options) - input = strconv.Quote(strings.ReplaceAll(input, `\"`, `"`)) - - result, err := pm.detailedExecutor.ExecWithWriter( + result, err := pm.detailedExecutor.ExecWithWriterArgs( ctx, - fmt.Sprintf(`tmux send-keys -t %s %s ENTER`, sessionName, input), + []string{"tmux", "send-keys", "-t", sessionName, input, "ENTER"}, out, options, ) @@ -241,9 +250,9 @@ func (pm *Tmux) makeTmuxInitialSession(ctx context.Context, server *domain.Serve runAsUser = currentUser.Username } - result, err = pm.detailedExecutor.ExecWithWriter( + result, err = pm.detailedExecutor.ExecWithWriterArgs( ctx, - fmt.Sprintf("su %s -c %s", runAsUser, strconv.Quote("tmux new -d -s gameap")), + []string{"su", runAsUser, "-c", "tmux new -d -s gameap"}, out, contracts.ExecutorOptions{ WorkDir: os.TempDir(), @@ -389,10 +398,9 @@ func (pm *Tmux) Attach( if !ok { return nil } - quoted := strconv.Quote(strings.ReplaceAll(line, `\"`, `"`)) - _, sendErr := pm.detailedExecutor.ExecWithWriter( + _, sendErr := pm.detailedExecutor.ExecWithWriterArgs( gctx, - fmt.Sprintf("tmux send-keys -t %s %s ENTER", sessionName, quoted), + []string{"tmux", "send-keys", "-t", sessionName, line, "ENTER"}, io.Discard, options, ) diff --git a/internal/processmanager/winsw_windows.go b/internal/processmanager/winsw_windows.go index 3e82b18..66a36ff 100644 --- a/internal/processmanager/winsw_windows.go +++ b/internal/processmanager/winsw_windows.go @@ -11,6 +11,7 @@ import ( "os" "os/user" "path/filepath" + "sort" "strconv" "strings" "time" @@ -362,25 +363,23 @@ func (pm *WinSW) makeService(ctx context.Context, server *domain.Server, out io. } func (pm *WinSW) buildServiceConfig(server *domain.Server) (string, error) { - cmd := domain.MakeFullCommand( + cmdArr, err := domain.BuildCommandArgs( pm.cfg, server, pm.cfg.Scripts.Start, server.StartCommand(), ) - - if cmd == "" { - return "", ErrEmptyCommand + if err != nil { + return "", errors.WithMessage(err, "failed to build command") } - cmdArr, err := shellquote.Split(cmd) - if err != nil { - return "", errors.WithMessage(err, "failed to split command") + if len(cmdArr) == 0 { + return "", ErrEmptyCommand } executable := cmdArr[0] - argArr := make([]string, 0, len(cmdArr)*2) + argArr := make([]string, 0, len(cmdArr)+1) if filepath.Ext(executable) == ".bat" { executable = "cmd.exe" @@ -391,8 +390,8 @@ func (pm *WinSW) buildServiceConfig(server *domain.Server) (string, error) { var arguments string - if len(cmdArr) > 1 { - arguments = strings.Join(argArr, " ") + if len(argArr) > 0 { + arguments = shellquote.WindowsJoin(argArr...) } serviceName := pm.serviceName(server) @@ -440,6 +439,16 @@ func (pm *WinSW) buildServiceConfig(server *domain.Server) (string, error) { serviceConfig.ServiceAccount.Username = server.User() serviceConfig.ServiceAccount.Password = password + envVars := server.EnvironmentVars() + envKeys := make([]string, 0, len(envVars)) + for k := range envVars { + envKeys = append(envKeys, k) + } + sort.Strings(envKeys) + for _, k := range envKeys { + serviceConfig.Env = append(serviceConfig.Env, winswEnv{Name: k, Value: envVars[k]}) + } + out, err := xml.MarshalIndent(struct { WinSWServiceConfig XMLName struct{} `xml:"service"` @@ -476,6 +485,8 @@ type WinSWServiceConfig struct { Arguments string `xml:"arguments,omitempty"` WorkingDirectory string `xml:"workingdirectory,omitempty"` + Env []winswEnv `xml:"env,omitempty"` + StopExecutable string `xml:"stopexecutable,omitempty"` StopArguments string `xml:"stoparguments,omitempty"` StopTimeout string `xml:"stoptimeout,omitempty"` @@ -494,6 +505,11 @@ type WinSWServiceConfig struct { } `xml:"serviceaccount,omitempty"` } +type winswEnv struct { + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` +} + type onFailure struct { Action string `xml:"action,attr"` Delay string `xml:"delay,attr,omitempty"` diff --git a/pkg/limiter/limiter.go b/pkg/limiter/limiter.go deleted file mode 100644 index cfef1cc..0000000 --- a/pkg/limiter/limiter.go +++ /dev/null @@ -1,125 +0,0 @@ -package limiter - -import ( - "context" - "sync" - "time" - - "github.com/gameap/daemon/internal/app/domain" - log "github.com/sirupsen/logrus" -) - -type CallScheduler struct { - q *Queue - singleCallFunc func(ctx context.Context, q *Queue) error - bulkCallFunc func(ctx context.Context, q *Queue) error - logger *log.Logger - duration time.Duration - bulkCallFromNum int -} - -func NewAPICallScheduler( - duration time.Duration, - bulkCallFromNum int, - singleCallFunc func(ctx context.Context, q *Queue) error, - bulkCallFunc func(ctx context.Context, q *Queue) error, - logger *log.Logger, -) *CallScheduler { - return &CallScheduler{ - q: NewQueue(), - duration: duration, - bulkCallFromNum: bulkCallFromNum, - singleCallFunc: singleCallFunc, - bulkCallFunc: bulkCallFunc, - logger: logger, - } -} - -func (s *CallScheduler) Run(ctx context.Context) { - ticker := time.NewTicker(s.duration) - - for { - select { - case <-ticker.C: - if s.q.Len() == 0 { - continue - } - - if s.q.Len() < s.bulkCallFromNum { - err := s.singleCallFunc(ctx, s.q) - if err != nil { - s.logger.Error(err) - } - } else { - err := s.bulkCallFunc(ctx, s.q) - if err != nil { - s.logger.Error(err) - } - } - case <-ctx.Done(): - s.logger.Info("Call scheduler stopped") - return - } - } -} - -func (s *CallScheduler) Put(server *domain.Server) { - s.q.Put(server) -} - -type Queue struct { - q []any - mutex sync.Mutex -} - -func NewQueue() *Queue { - return &Queue{ - q: make([]any, 0), - } -} - -func (q *Queue) Put(item any) { - q.mutex.Lock() - defer q.mutex.Unlock() - - q.q = append(q.q, item) -} - -func (q *Queue) Get() any { - q.mutex.Lock() - defer q.mutex.Unlock() - - if len(q.q) == 0 { - return nil - } - - item := q.q[0] - q.q = q.q[1:] - - return item -} - -func (q *Queue) GetN(n int) []any { - q.mutex.Lock() - defer q.mutex.Unlock() - - if len(q.q) == 0 { - return nil - } - - if len(q.q) < n { - n = len(q.q) - } - - items := q.q[:n] - q.q = q.q[n:] - - return items -} - -func (q *Queue) Len() int { - q.mutex.Lock() - defer q.mutex.Unlock() - - return len(q.q) -} diff --git a/pkg/limiter/limiter_test.go b/pkg/limiter/limiter_test.go deleted file mode 100644 index be6d3d8..0000000 --- a/pkg/limiter/limiter_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package limiter - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/gameap/daemon/internal/app/config" - "github.com/gameap/daemon/internal/app/domain" - "github.com/gameap/daemon/pkg/logger" - "github.com/stretchr/testify/assert" -) - -func Test_Limiter(t *testing.T) { - calledSingle := 0 - calledBulk := 0 - count := 0 - - s := NewAPICallScheduler( - 10*time.Millisecond, - 5, - func(_ context.Context, q *Queue) error { - q.Get() - calledSingle++ - count++ - return nil - }, - func(_ context.Context, q *Queue) error { - n := q.GetN(10) - calledBulk++ - count += len(n) - return nil - }, - logger.NewLogger(config.Config{}), - ) - - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - wg := sync.WaitGroup{} - wg.Add(1) - - go func() { - s.Run(ctx) - wg.Done() - }() - - s.Put(&domain.Server{}) - s.Put(&domain.Server{}) - time.Sleep(100 * time.Millisecond) - s.Put(&domain.Server{}) - time.Sleep(100 * time.Millisecond) - for i := 50; i > 0; i-- { - s.Put(&domain.Server{}) - } - wg.Wait() - - assert.Equal(t, 3, calledSingle) - assert.Equal(t, 5, calledBulk) - assert.Equal(t, 53, count) -} diff --git a/pkg/shellquote/unquote.go b/pkg/shellquote/unquote.go index 53b4647..38b2e41 100644 --- a/pkg/shellquote/unquote.go +++ b/pkg/shellquote/unquote.go @@ -23,20 +23,59 @@ func Join(words ...string) string { return shellquote.Join(words...) } -// WindowsArgToString quotes a string for use as a Windows command line argument. -// It wraps the string in double quotes if it contains spaces or special characters, -// and escapes any existing double quotes. +// WindowsArgToString quotes a string for use as a single Windows command line +// argument. It follows the backslash/quote rules parsed by CommandLineToArgvW +// (and the MSVC runtime): a run of backslashes is doubled only when it precedes +// a double quote or the closing quote, and an embedded double quote is escaped +// as \". A value ending in a backslash therefore round-trips correctly instead +// of escaping the closing quote. func WindowsArgToString(s string) string { if s == "" { return `""` } - needsQuoting := strings.ContainsAny(s, " \t\"") - if !needsQuoting { + if !strings.ContainsAny(s, " \t\n\v\"") { return s } - // Escape double quotes and wrap in quotes - escaped := strings.ReplaceAll(s, `"`, `\"`) - return `"` + escaped + `"` + var b strings.Builder + b.Grow(len(s) + 2) + b.WriteByte('"') + + for i := 0; i < len(s); { + backslashes := 0 + for i < len(s) && s[i] == '\\' { + i++ + backslashes++ + } + + switch { + case i == len(s): + b.WriteString(strings.Repeat(`\`, backslashes*2)) + case s[i] == '"': + b.WriteString(strings.Repeat(`\`, backslashes*2+1)) + b.WriteByte('"') + i++ + default: + b.WriteString(strings.Repeat(`\`, backslashes)) + b.WriteByte(s[i]) + i++ + } + } + + b.WriteByte('"') + + return b.String() +} + +// WindowsJoin quotes each argument with WindowsArgToString and joins them with +// spaces, producing the argument portion of a Windows command line that +// CommandLineToArgvW parses back into exactly these arguments. +func WindowsJoin(args ...string) string { + quoted := make([]string, len(args)) + for i, a := range args { + quoted[i] = WindowsArgToString(a) + } + + return strings.Join(quoted, " ") } diff --git a/pkg/shellquote/windows_arg_test.go b/pkg/shellquote/windows_arg_test.go new file mode 100644 index 0000000..87bd1bc --- /dev/null +++ b/pkg/shellquote/windows_arg_test.go @@ -0,0 +1,37 @@ +package shellquote + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWindowsArgToString(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty", "", `""`}, + {"no_special_chars", "simple", "simple"}, + {"backslashes_no_space", `C:\path\to`, `C:\path\to`}, + {"space", "a b", `"a b"`}, + {"single_quote_and_space", "Andrey's Server", `"Andrey's Server"`}, + {"embedded_quote", `a"b`, `"a\"b"`}, + {"quote_only", `"`, `"\""`}, + {"space_and_trailing_backslash", `C:\path with space\`, `"C:\path with space\\"`}, + {"space_and_backslash_before_quote", `a \"b`, `"a \\\"b"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, WindowsArgToString(tt.input)) + }) + } +} + +func TestWindowsJoin(t *testing.T) { + got := WindowsJoin("game.exe", "--name", "Andrey's Server", "--path", `C:\a b\`) + + assert.Equal(t, `game.exe --name "Andrey's Server" --path "C:\a b\\"`, got) +} diff --git a/test/files/test.7z b/test/files/test.7z new file mode 100644 index 0000000..209a6cc Binary files /dev/null and b/test/files/test.7z differ diff --git a/test/files/test.rar b/test/files/test.rar new file mode 100644 index 0000000..c12a6a8 Binary files /dev/null and b/test/files/test.rar differ diff --git a/test/functional/gdtasks/commands/cmdexec_test.go b/test/functional/gdtasks/commands/cmdexec_test.go index 4c4788b..25efa66 100644 --- a/test/functional/gdtasks/commands/cmdexec_test.go +++ b/test/functional/gdtasks/commands/cmdexec_test.go @@ -87,7 +87,7 @@ func (suite *Suite) givenDependentGDTaskWithCommand(runAfterID int, cmd string) domain.GDTaskStatusWaiting, ) - suite.GDTaskRepository.Set([]*domain.GDTask{task}) + suite.InsertTask(task) return task } diff --git a/test/functional/gdtasks/commands/suite_test.go b/test/functional/gdtasks/commands/suite_test.go index 65c2b08..6cd7132 100644 --- a/test/functional/gdtasks/commands/suite_test.go +++ b/test/functional/gdtasks/commands/suite_test.go @@ -43,8 +43,6 @@ func (suite *Suite) SetupTest() { } func (suite *Suite) TearDownTest() { - suite.GDTaskRepository.Clear() - err := os.RemoveAll(suite.WorkPath) if err != nil { suite.T().Log(err) diff --git a/test/functional/gdtasks/suite.go b/test/functional/gdtasks/suite.go index d6dcea8..43f5952 100644 --- a/test/functional/gdtasks/suite.go +++ b/test/functional/gdtasks/suite.go @@ -27,19 +27,21 @@ type Suite struct { functional.GameServerSuite TaskManager *gdaemonscheduler.TaskManager - GDTaskRepository *mocks.GDTaskRepository ServerRepository *mocks.ServerRepository Executor contracts.Executor ProcessManager contracts.ProcessManager Cache contracts.Cache Cfg *config.Config + insertedTasks map[int]*domain.GDTask + WorkPath string } func (suite *Suite) SetupTest() { + suite.insertedTasks = map[int]*domain.GDTask{} + suite.TaskManager = gdaemonscheduler.NewTaskManager( - suite.GDTaskRepository, suite.Cache, gameservercommands.NewFactory( suite.Cfg, @@ -55,7 +57,6 @@ func (suite *Suite) SetupTest() { func (suite *Suite) SetupSuite() { var err error - suite.GDTaskRepository = mocks.NewGDTaskRepository() suite.ServerRepository = mocks.NewServerRepository() suite.Cfg = &config.Config{ @@ -130,13 +131,16 @@ func (suite *Suite) isAllTasksCompleted(tasks []*domain.GDTask) bool { return counter >= len(tasks) } +func (suite *Suite) InsertTask(task *domain.GDTask) { + suite.TaskManager.InsertTask(task) + suite.insertedTasks[task.ID()] = task +} + func (suite *Suite) AssertGDTaskExist(task *domain.GDTask) { suite.T().Helper() - actualTask, err := suite.GDTaskRepository.FindByID(context.Background(), task.ID()) - if err != nil { - suite.T().Fatal(err) - } + actualTask, ok := suite.insertedTasks[task.ID()] + suite.Require().True(ok, "task %d was not inserted", task.ID()) suite.Require().NotNil(actualTask) suite.Assert().Equal(task.Status(), actualTask.Status()) @@ -161,7 +165,7 @@ func (suite *Suite) GivenGDTaskWithCommand(cmd string) *domain.GDTask { domain.GDTaskStatusWaiting, ) - suite.GDTaskRepository.Set([]*domain.GDTask{task}) + suite.InsertTask(task) return task } @@ -176,7 +180,7 @@ func (suite *Suite) GivenGDTaskWithIDForServer(id int, server *domain.Server) *d domain.GDTaskStatusWaiting, ) - suite.GDTaskRepository.Set([]*domain.GDTask{task}) + suite.InsertTask(task) return task } @@ -230,7 +234,9 @@ func (suite *Suite) GivenSequenceGDTaskForServer(server *domain.Server) []*domai rand.New(rand.NewSource(time.Now().UnixNano())) rand.Shuffle(len(tasks), func(i, j int) { tasks[i], tasks[j] = tasks[j], tasks[i] }) - suite.GDTaskRepository.Set(tasks) + for _, task := range tasks { + suite.InsertTask(task) + } return tasks } diff --git a/test/functional/repositoriestest/fixtures.go b/test/functional/repositoriestest/fixtures.go deleted file mode 100644 index 815be60..0000000 --- a/test/functional/repositoriestest/fixtures.go +++ /dev/null @@ -1,270 +0,0 @@ -package repositoriestest - -var JSONApiGetServerResponseBody = []byte(` -{ - "id": 1, - "uuid": "94cdfde4-15a4-40b9-8043-260e6a0b5b67", - "uuid_short": "94cdfde4", - "enabled": true, - "installed": 1, - "blocked": false, - "name": "Test", - "game_id": "cstrike", - "ds_id": 1, - "game_mod_id": 4, - "expires": null, - "server_ip": "172.24.0.5", - "server_port": 27015, - "query_port": 27015, - "rcon_port": 27015, - "rcon": "57jPyiVYTO", - "dir": "servers/94cdfde4-15a4-40b9-8043-260e6a0b5b67", - "su_user": "gameap", - "cpu_limit": 2000, - "ram_limit": 2147483648, - "net_limit": null, - "start_command": "./hlds_run -game cstrike +ip {ip} +port {port} +map {default_map} +maxplayers {maxplayers} +sys_ticrate {fps} +rcon_password {rcon_password}", - "stop_command": null, - "force_stop_command": null, - "restart_command": null, - "process_active": true, - "last_process_check": "2021-11-05 19:57:11", - "vars": { - "default_map": "de_dust2" - }, - "created_at": "2021-11-05T15:01:27.000000Z", - "updated_at": "2021-11-05T19:57:11.000000Z", - "deleted_at": null, - "game": { - "code": "cstrike", - "start_code": "cstrike", - "name": "Counter-Strike 1.6", - "engine": "GoldSource", - "engine_version": "1", - "steam_app_id": 90, - "steam_app_set_config": null, - "remote_repository": "http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", - "local_repository": "/srv/gameap/repository/hlcs_base.tar.xz", - "metadata": { - "custom_key": "custom_value" - } - }, - "game_mod": { - "id": 4, - "game_code": "cstrike", - "name": "Classic (Standart)", - "fast_rcon": [ - { - "info": "Status", - "command": "status" - }, - { - "info": "Stats", - "command": "stats" - }, - { - "info": "Last disconnect players", - "command": "amx_last" - }, - { - "info": "Admins on servers", - "command": "amx_who" - } - ], - "vars": [ - { - "var": "default_map", - "default": "de_dust2", - "info": "Default Map", - "admin_var": false - }, - { - "var": "fps", - "default": 500, - "info": "Server FPS (tickrate)", - "admin_var": true - }, - { - "var": "maxplayers", - "default": 32, - "info": "Maximum players on server", - "admin_var": false - } - ], - "remote_repository": "http://files.gameap.ru/cstrike-1.6/amxx.tar.xz", - "local_repository": "/srv/gameap/repository/cstrike-1.6/amxx.tar.xz", - "default_start_cmd_linux": "./hlds_run -game cstrike +ip {ip} +port {port} +map {default_map} +maxplayers {maxplayers} +sys_ticrate {fps} +rcon_password {rcon_password}", - "default_start_cmd_windows": "hlds.exe -console -game cstrike +ip {ip} +port {port} +map {default_map} +maxplayers {maxplayers} +sys_ticrate {fps} +rcon_password {rcon_password}", - "kick_cmd": "kick #{id}", - "ban_cmd": "amx_ban \"{name}\" {time} \"{reason}\"", - "chname_cmd": "amx_nick #{id} {name}", - "srestart_cmd": "restart", - "chmap_cmd": "changelevel {map}", - "sendmsg_cmd": "amx_say \"{msg}\"", - "passwd_cmd": "password {password}", - "metadata": { - "mod_key": "mod_value" - } - }, - "settings": [ - { - "id": 1, - "name": "autostart_current", - "server_id": 1, - "value": "1" - } - ] -}`) - -var JSONApiGetTokenResponseBody = []byte(` -{ - "token": "dYCw9ADVnS03leY9dLlckgaxiG59uKF3KMCcpmXpJUKYmlQXuAhvHtCYbL6hG3Ce", - "timestamp": 0 -} -`) - -var JSONApiGetServersTasks = []byte(` -[ - { - "id": 1, - "command": "restart", - "server_id": 1, - "repeat": 0, - "repeat_period": 600, - "counter": 0, - "execute_date": "2021-11-14 00:00:00", - "payload": null, - "created_at": "2021-11-13T11:41:32.000000Z", - "updated_at": "2021-11-13T12:44:41.000000Z" - } -] -`) - -var JSONApiGetServerFactorioResponseBody = []byte(` -{ - "id": 2, - "uuid": "9c3dea74-b4d6-4e2f-9f4e-6c97b6e3f9a2", - "uuid_short": "9c3dea74", - "enabled": true, - "installed": 1, - "blocked": false, - "name": "Factorio Test Server", - "game_id": "factorio", - "ds_id": 1, - "game_mod_id": 10, - "expires": null, - "server_ip": "192.168.1.100", - "server_port": 27023, - "query_port": 27023, - "rcon_port": 27023, - "rcon": "factoriorcon", - "dir": "servers/9c3dea74-b4d6-4e2f-9f4e-6c97b6e3f9a2", - "su_user": "gameap", - "cpu_limit": 4000, - "ram_limit": 4294967296, - "net_limit": null, - "start_command": "./factorio --start-server {SAVE_NAME}.zip --server-settings server-settings.json", - "stop_command": null, - "force_stop_command": null, - "restart_command": null, - "process_active": false, - "last_process_check": "2024-01-15 10:30:00", - "vars": null, - "created_at": "2024-01-15T08:00:00.000000Z", - "updated_at": "2024-01-15T10:30:00.000000Z", - "deleted_at": null, - "game": { - "code": "factorio", - "start_code": "factorio", - "name": "Factorio", - "engine": "Factorio", - "engine_version": "1", - "steam_app_id": 427520, - "steam_app_set_config": null, - "remote_repository": "http://files.gameap.ru/factorio/factorio_headless.tar.xz", - "local_repository": "/srv/gameap/repository/factorio_headless.tar.xz", - "metadata": null - }, - "game_mod": { - "id": 10, - "game_code": "factorio", - "name": "Vanilla", - "fast_rcon": null, - "vars": [ - { - "var": "FACTORIO_VERSION", - "default": "latest", - "info": "Factorio Version", - "admin_var": true - }, - { - "var": "MAX_SLOTS", - "default": 10, - "info": "Maximum player slots", - "admin_var": false - }, - { - "var": "SAVE_NAME", - "default": "world", - "info": "Save file name", - "admin_var": false - }, - { - "var": "SERVER_DESC", - "default": "A Factorio Server", - "info": "Server Description", - "admin_var": false - } - ], - "remote_repository": "http://files.gameap.ru/factorio/vanilla.tar.xz", - "local_repository": "/srv/gameap/repository/factorio/vanilla.tar.xz", - "default_start_cmd_linux": "./factorio --start-server {SAVE_NAME}.zip --server-settings server-settings.json", - "default_start_cmd_windows": "factorio.exe --start-server {SAVE_NAME}.zip --server-settings server-settings.json", - "kick_cmd": "/kick {name}", - "ban_cmd": "/ban {name} {reason}", - "chname_cmd": null, - "srestart_cmd": null, - "chmap_cmd": null, - "sendmsg_cmd": null, - "passwd_cmd": null, - "metadata": null - }, - "settings": [ - { - "id": 10, - "name": "update_before_start", - "server_id": 2, - "value": "false" - }, - { - "id": 11, - "name": "SERVER_DESC", - "server_id": 2, - "value": "Description" - }, - { - "id": 12, - "name": "SERVER_USERNAME", - "server_id": 2, - "value": "unnamed" - }, - { - "id": 13, - "name": "FACTORIO_VERSION", - "server_id": 2, - "value": "1.1.100" - }, - { - "id": 14, - "name": "MAX_SLOTS", - "server_id": 2, - "value": "20" - }, - { - "id": 15, - "name": "SAVE_NAME", - "server_id": 2, - "value": "gamesave" - } - ] -}`) diff --git a/test/functional/repositoriestest/gdtaskrepository/fixtures_test.go b/test/functional/repositoriestest/gdtaskrepository/fixtures_test.go deleted file mode 100644 index 52fc4dc..0000000 --- a/test/functional/repositoriestest/gdtaskrepository/fixtures_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package gdtaskrepository - -var jsonWaitingTasksResponseBody = []byte(` -[ - { - "id": 3, - "run_aft_id": 1, - "created_at": "2021-11-05T21:57:36.000000Z", - "updated_at": "2021-11-05T21:57:36.000000Z", - "dedicated_server_id": 1, - "server_id": 1, - "task": "gsinst", - "data": null, - "cmd": null, - "status": "waiting", - "status_num": 1 - } -] -`) - -var jsonWaitingTasksWithEmptyServerResponseBody = []byte(` -[ - { - "id": 3, - "run_aft_id": 1, - "created_at": "2021-11-05T21:57:36.000000Z", - "updated_at": "2021-11-05T21:57:36.000000Z", - "dedicated_server_id": 1, - "server_id": 0, - "task": "cmdexec", - "data": null, - "cmd": "./task_command.sh", - "status": "waiting", - "status_num": 1 - } -] -`) diff --git a/test/functional/repositoriestest/gdtaskrepository/gdtask_repository_test.go b/test/functional/repositoriestest/gdtaskrepository/gdtask_repository_test.go deleted file mode 100644 index 1260d3a..0000000 --- a/test/functional/repositoriestest/gdtaskrepository/gdtask_repository_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package gdtaskrepository - -import ( - "context" - "net/http" - - "github.com/gameap/daemon/internal/app/domain" - "github.com/gameap/daemon/test/functional" - "github.com/gameap/daemon/test/functional/repositoriestest" -) - -func (suite *Suite) TestFindByStatus_Success() { - suite.GivenAPIResponse( - "/gdaemon_api/tasks?append=status_num&filter%5Bstatus%5D=waiting", - http.StatusOK, - jsonWaitingTasksResponseBody, - ) - suite.GivenAPIResponse("/gdaemon_api/servers/1", http.StatusOK, repositoriestest.JSONApiGetServerResponseBody) - - gdtasks, err := suite.GDTaskRepository.FindByStatus(context.Background(), domain.GDTaskStatusWaiting) - - suite.Require().Nil(err) - suite.Require().NotNil(gdtasks) - suite.Require().Len(gdtasks, 1) - suite.Equal(3, gdtasks[0].ID()) - suite.Equal(domain.GDTaskGameServerInstall, gdtasks[0].Task()) - suite.Equal(domain.GDTaskStatusWaiting, gdtasks[0].Status()) - suite.Require().NotNil(gdtasks[0].Server()) - suite.Equal(1, gdtasks[0].Server().ID()) -} - -func (suite *Suite) TestFindByStatus_EmptyServer_Success() { - suite.GivenAPIResponse( - "/gdaemon_api/tasks?append=status_num&filter%5Bstatus%5D=waiting", - http.StatusOK, - jsonWaitingTasksWithEmptyServerResponseBody, - ) - - gdtasks, err := suite.GDTaskRepository.FindByStatus(context.Background(), domain.GDTaskStatusWaiting) - - suite.Require().Nil(err) - suite.Require().NotNil(gdtasks) - suite.Require().Len(gdtasks, 1) - suite.Equal(3, gdtasks[0].ID()) - suite.Equal(domain.GDTaskCommandExecute, gdtasks[0].Task()) - suite.Equal(domain.GDTaskStatusWaiting, gdtasks[0].Status()) - suite.Equal("./task_command.sh", gdtasks[0].Command()) - suite.Require().Nil(gdtasks[0].Server()) -} - -func (suite *Suite) TestSave_Success() { - suite.GivenAPIResponse("/gdaemon_api/servers/1337", http.StatusOK, repositoriestest.JSONApiGetServerResponseBody) - suite.GivenAPIResponse("/gdaemon_api/tasks/2", http.StatusOK, nil) - suite.GivenAPIResponse("/gdaemon_api/servers/1337", http.StatusOK, nil) - gdTask := domain.NewGDTask( - 2, - 0, - functional.GameServer, - domain.GDTaskGameServerStart, - "", - domain.GDTaskStatusSuccess, - ) - - err := suite.GDTaskRepository.Save(context.Background(), gdTask) - - suite.Require().Nil(err) - suite.AssertAPIPutCalled( - "/gdaemon_api/tasks/2", - []byte(`{"status":4}`), - ) -} - -func (suite *Suite) TestAppendOutput_Success() { - suite.GivenAPIResponse("/gdaemon_api/tasks/2/output", http.StatusOK, nil) - gdTask := domain.NewGDTask( - 2, - 0, - functional.GameServer, - domain.GDTaskGameServerStart, - "", - domain.GDTaskStatusSuccess, - ) - - err := suite.GDTaskRepository.AppendOutput(context.Background(), gdTask, []byte("output contents")) - - suite.Require().Nil(err) - suite.AssertAPIPutCalled( - "/gdaemon_api/tasks/2/output", - []byte(`{"output":"output contents"}`), - ) -} diff --git a/test/functional/repositoriestest/gdtaskrepository/suite_test.go b/test/functional/repositoriestest/gdtaskrepository/suite_test.go deleted file mode 100644 index a15f95f..0000000 --- a/test/functional/repositoriestest/gdtaskrepository/suite_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package gdtaskrepository - -import ( - "context" - "testing" - - "github.com/gameap/daemon/internal/app/repositories" - "github.com/gameap/daemon/test/functional/repositoriestest" - "github.com/stretchr/testify/suite" -) - -type Suite struct { - repositoriestest.Suite - - GDTaskRepository *repositories.GDTaskRepository -} - -func TestSuite(t *testing.T) { - suite.Run(t, new(Suite)) -} - -func (suite *Suite) SetupSuite() { - suite.Suite.SetupSuite() - - gdTaskRepository, err := suite.Container.GdTaskRepository(context.TODO()) - if err != nil { - suite.T().Fatal(err) - } - - suite.GDTaskRepository = gdTaskRepository.(*repositories.GDTaskRepository) -} - -func (suite *Suite) SetupTest() { - suite.Suite.SetupTest() -} diff --git a/test/functional/repositoriestest/server_task_repository/server_task_repository_test.go b/test/functional/repositoriestest/server_task_repository/server_task_repository_test.go deleted file mode 100644 index 231fb17..0000000 --- a/test/functional/repositoriestest/server_task_repository/server_task_repository_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package servertaskrepository - -import ( - "context" - "net/http" - "time" - - "github.com/gameap/daemon/internal/app/domain" - "github.com/gameap/daemon/test/functional" - "github.com/gameap/daemon/test/functional/repositoriestest" -) - -func (suite *Suite) TestFind_Success() { - suite.GivenAPIResponse( - "/gdaemon_api/servers_tasks", - http.StatusOK, - repositoriestest.JSONApiGetServersTasks, - ) - suite.GivenAPIResponse( - "/gdaemon_api/servers/1", - http.StatusOK, - repositoriestest.JSONApiGetServerResponseBody, - ) - - tasks, err := suite.ServerTaskRepository.Find(context.Background()) - - suite.Require().Nil(err) - suite.Require().NotNil(tasks) - suite.Assert().Len(tasks, 1) - suite.Assert().Equal(1, tasks[0].ID()) - suite.Assert().Equal(domain.ServerTaskRestart, tasks[0].Command()) - suite.Require().NotNil(tasks[0].Server()) - suite.Assert().Equal(1, tasks[0].Server().ID()) - suite.Assert().Equal(0, tasks[0].Repeat()) - suite.Assert().Equal(10*time.Minute, tasks[0].RepeatPeriod()) - suite.Assert().Equal(0, tasks[0].Counter()) - suite.Assert().Equal(time.Date(2021, 11, 14, 0, 0, 0, 0, time.UTC), tasks[0].ExecuteDate()) -} - -func (suite *Suite) TestSave_Success() { - suite.GivenAPIResponse("/gdaemon_api/servers_tasks/2", http.StatusOK, nil) - task := domain.NewServerTask( - 2, - domain.ServerTaskStart, - functional.GameServer, - 2, - 1*time.Hour, - 10, - time.Date(2021, 11, 14, 0, 0, 0, 0, time.UTC), - ) - - err := suite.ServerTaskRepository.Save(context.Background(), task) - - suite.Require().Nil(err) - suite.AssertAPIPutCalled( - "/gdaemon_api/servers_tasks/2", - []byte(`{"repeat":2,"repeat_period":3600,"execute_date":"2021-11-14 00:00:00"}`), - ) -} - -func (suite *Suite) TestFail_Success() { - suite.GivenAPIResponse("/gdaemon_api/servers_tasks/2/fail", http.StatusCreated, nil) - task := domain.NewServerTask( - 2, - domain.ServerTaskStart, - functional.GameServer, - 2, - 1*time.Hour, - 10, - time.Date(2021, 11, 14, 0, 0, 0, 0, time.UTC), - ) - - err := suite.ServerTaskRepository.Fail(context.Background(), task, []byte(`output contents`)) - - suite.Require().Nil(err) - suite.AssertAPIPostCalled( - "/gdaemon_api/servers_tasks/2/fail", - []byte(`{"output":"output contents"}`), - ) -} diff --git a/test/functional/repositoriestest/server_task_repository/suite_test.go b/test/functional/repositoriestest/server_task_repository/suite_test.go deleted file mode 100644 index d8f8e7e..0000000 --- a/test/functional/repositoriestest/server_task_repository/suite_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package servertaskrepository - -import ( - "context" - "testing" - - "github.com/gameap/daemon/internal/app/repositories" - "github.com/gameap/daemon/test/functional/repositoriestest" - "github.com/stretchr/testify/suite" -) - -type Suite struct { - repositoriestest.Suite - - ServerTaskRepository *repositories.ServerTaskRepository -} - -func TestSuite(t *testing.T) { - suite.Run(t, new(Suite)) -} - -func (suite *Suite) SetupSuite() { - suite.Suite.SetupSuite() - - serverTaskRepository, err := suite.Container.ServerTaskRepository(context.TODO()) - if err != nil { - suite.T().Fatal(err) - } - - suite.ServerTaskRepository = serverTaskRepository.(*repositories.ServerTaskRepository) -} - -func (suite *Suite) SetupTest() { - suite.Suite.SetupTest() -} diff --git a/test/functional/repositoriestest/serverrepository/server_repository_test.go b/test/functional/repositoriestest/serverrepository/server_repository_test.go deleted file mode 100644 index de9dadd..0000000 --- a/test/functional/repositoriestest/serverrepository/server_repository_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package serverrepository - -import ( - "context" - "net/http" - - "github.com/gameap/daemon/test/functional/repositoriestest" -) - -func (suite *Suite) TestNotFound() { - server, err := suite.ServerRepository.FindByID(context.Background(), 99999) - - suite.Require().Nil(err) - suite.Require().Nil(server) -} - -func (suite *Suite) TestSuccess() { - suite.GivenAPIResponse("/gdaemon_api/servers/1", http.StatusOK, repositoriestest.JSONApiGetServerResponseBody) - - server, err := suite.ServerRepository.FindByID(context.Background(), 1) - - suite.Require().Nil(err) - suite.Require().NotNil(server) - suite.Equal(1, server.ID()) - suite.Equal("94cdfde4-15a4-40b9-8043-260e6a0b5b67", server.UUID()) - suite.Equal("94cdfde4", server.UUIDShort()) - suite.Equal("1", server.Setting("autostart_current")) - suite.Equal("servers/94cdfde4-15a4-40b9-8043-260e6a0b5b67", server.Dir()) - suite.Equal("172.24.0.5", server.IP()) - suite.Equal(27015, server.ConnectPort()) - suite.Equal(27015, server.QueryPort()) - suite.Equal(27015, server.RCONPort()) - suite.Equal("57jPyiVYTO", server.RCONPassword()) - suite.Equal("gameap", server.User()) - suite.Equal("./hlds_run -game cstrike +ip {ip} +port {port} +map {default_map} +maxplayers {maxplayers} +sys_ticrate {fps} +rcon_password {rcon_password}", server.StartCommand()) - suite.Equal(map[string]string{ - "default_map": "de_dust2", - "fps": "500", - "maxplayers": "32", - "autostart_current": "1", - }, server.Vars()) - suite.Equal(true, server.AutoStart()) - suite.Equal("cstrike", server.Game().Code) - suite.Equal("cstrike", server.Game().StartCode) - suite.Equal("GoldSource", server.Game().Engine) - suite.Equal("1", server.Game().EngineVersion) - suite.Equal("http://files.gameap.ru/cstrike-1.6/hlcs_base.tar.xz", server.Game().RemoteRepository) - suite.Equal("/srv/gameap/repository/hlcs_base.tar.xz", server.Game().LocalRepository) - suite.Equal("Counter-Strike 1.6", server.Game().Name) - suite.Equal(4, server.GameMod().ID) - suite.Equal("Classic (Standart)", server.GameMod().Name) - suite.Equal("http://files.gameap.ru/cstrike-1.6/amxx.tar.xz", server.GameMod().RemoteRepository) - suite.Equal("/srv/gameap/repository/cstrike-1.6/amxx.tar.xz", server.GameMod().LocalRepository) - suite.Equal("./hlds_run -game cstrike +ip {ip} +port {port} +map {default_map} +maxplayers {maxplayers} +sys_ticrate {fps} +rcon_password {rcon_password}", server.GameMod().DefaultStartCMDLinux) - suite.Equal("hlds.exe -console -game cstrike +ip {ip} +port {port} +map {default_map} +maxplayers {maxplayers} +sys_ticrate {fps} +rcon_password {rcon_password}", server.GameMod().DefaultStartCMDWindows) - suite.Equal("custom_value", server.Game().Metadata["custom_key"]) - suite.Equal("mod_value", server.GameMod().Metadata["mod_key"]) -} - -func (suite *Suite) TestWhenTokenIsInvalid_ExpectSuccess() { - suite.GivenAPIResponse("/gdaemon_api/servers/1", http.StatusUnauthorized, nil) - suite.GivenAPIResponse("/gdaemon_api/get_token", http.StatusOK, repositoriestest.JSONApiGetTokenResponseBody) - suite.GivenAPIResponse("/gdaemon_api/servers/1", http.StatusOK, repositoriestest.JSONApiGetServerResponseBody) - - server, err := suite.ServerRepository.FindByID(context.Background(), 1) - - suite.Require().Nil(err) - suite.Require().NotNil(server) - suite.Equal(1, server.ID()) -} - -func (suite *Suite) TestFactorioServerParsing() { - suite.GivenAPIResponse("/gdaemon_api/servers/2", http.StatusOK, repositoriestest.JSONApiGetServerFactorioResponseBody) - - server, err := suite.ServerRepository.FindByID(context.Background(), 2) - - suite.Require().Nil(err) - suite.Require().NotNil(server) - - // Basic server info - suite.Equal(2, server.ID()) - suite.Equal("9c3dea74-b4d6-4e2f-9f4e-6c97b6e3f9a2", server.UUID()) - suite.Equal("9c3dea74", server.UUIDShort()) - suite.Equal("servers/9c3dea74-b4d6-4e2f-9f4e-6c97b6e3f9a2", server.Dir()) - suite.Equal("192.168.1.100", server.IP()) - suite.Equal(27023, server.ConnectPort()) - suite.Equal(27023, server.QueryPort()) - suite.Equal(27023, server.RCONPort()) - suite.Equal("factoriorcon", server.RCONPassword()) - suite.Equal("gameap", server.User()) - - // Server settings parsed correctly from array format - suite.Equal("false", server.Setting("update_before_start")) - suite.Equal("Description", server.Setting("SERVER_DESC")) - suite.Equal("unnamed", server.Setting("SERVER_USERNAME")) - suite.Equal("1.1.100", server.Setting("FACTORIO_VERSION")) - suite.Equal("20", server.Setting("MAX_SLOTS")) - suite.Equal("gamesave", server.Setting("SAVE_NAME")) - - vars := server.Vars() - suite.Equal("1.1.100", vars["FACTORIO_VERSION"]) - suite.Equal("20", vars["MAX_SLOTS"]) - suite.Equal("gamesave", vars["SAVE_NAME"]) - suite.Equal("Description", vars["SERVER_DESC"]) - - // Game info - suite.Equal("factorio", server.Game().Code) - suite.Equal("factorio", server.Game().StartCode) - suite.Equal("Factorio", server.Game().Engine) - suite.Equal("Factorio", server.Game().Name) - - // GameMod info - suite.Equal(10, server.GameMod().ID) - suite.Equal("Vanilla", server.GameMod().Name) - suite.Len(server.GameMod().Vars, 4) -} - -func (suite *Suite) TestEnvironmentVars() { - suite.GivenAPIResponse("/gdaemon_api/servers/2", http.StatusOK, repositoriestest.JSONApiGetServerFactorioResponseBody) - - server, err := suite.ServerRepository.FindByID(context.Background(), 2) - - suite.Require().Nil(err) - suite.Require().NotNil(server) - - envVars := server.EnvironmentVars() - - // Port values are always set - suite.Equal("27023", envVars["SERVER_PORT"]) - suite.Equal("27023", envVars["PORT"]) - suite.Equal("27023", envVars["QUERY_PORT"]) - suite.Equal("27023", envVars["RCON_PORT"]) - - // Settings override gameMod.Vars defaults - // FACTORIO_VERSION: default "latest" -> setting "1.1.100" - suite.Equal("1.1.100", envVars["FACTORIO_VERSION"]) - // MAX_SLOTS: default "10" -> setting "20" - suite.Equal("20", envVars["MAX_SLOTS"]) - // SAVE_NAME: default "world" -> setting "gamesave" - suite.Equal("gamesave", envVars["SAVE_NAME"]) - // SERVER_DESC: default "A Factorio Server" -> setting "Description" - suite.Equal("Description", envVars["SERVER_DESC"]) - - // Additional settings that weren't in gameMod.Vars (keys are normalized) - suite.Equal("false", envVars["UPDATE_BEFORE_START"]) - suite.Equal("unnamed", envVars["SERVER_USERNAME"]) -} - -func (suite *Suite) TestEnvironmentVarsWithServerVars() { - suite.GivenAPIResponse("/gdaemon_api/servers/1", http.StatusOK, repositoriestest.JSONApiGetServerResponseBody) - - server, err := suite.ServerRepository.FindByID(context.Background(), 1) - - suite.Require().Nil(err) - suite.Require().NotNil(server) - - envVars := server.EnvironmentVars() - - // Port values are always set - suite.Equal("27015", envVars["SERVER_PORT"]) - suite.Equal("27015", envVars["PORT"]) - suite.Equal("27015", envVars["QUERY_PORT"]) - suite.Equal("27015", envVars["RCON_PORT"]) - - // server.vars override gameMod.Vars defaults (keys are normalized to uppercase) - // default_map: gameMod default "de_dust2" -> server var "de_dust2" (same in this case) - suite.Equal("de_dust2", envVars["DEFAULT_MAP"]) - // fps: gameMod default "500" (no override) - suite.Equal("500", envVars["FPS"]) - // maxplayers: gameMod default "32" (no override) - suite.Equal("32", envVars["MAXPLAYERS"]) - - // Settings from server.settings (keys are normalized) - suite.Equal("1", envVars["AUTOSTART_CURRENT"]) -} diff --git a/test/functional/repositoriestest/serverrepository/suite_test.go b/test/functional/repositoriestest/serverrepository/suite_test.go deleted file mode 100644 index f53f701..0000000 --- a/test/functional/repositoriestest/serverrepository/suite_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package serverrepository - -import ( - "context" - "testing" - - "github.com/gameap/daemon/internal/app/repositories" - "github.com/gameap/daemon/test/functional/repositoriestest" - "github.com/stretchr/testify/suite" -) - -type Suite struct { - repositoriestest.Suite - - ServerRepository *repositories.ServerRepository -} - -func TestSuite(t *testing.T) { - suite.Run(t, new(Suite)) -} - -func (suite *Suite) SetupSuite() { - suite.Suite.SetupSuite() - - serverRepository, err := suite.Container.ServerRepository(context.TODO()) - if err != nil { - suite.T().Fatal(err) - } - - suite.ServerRepository = serverRepository.(*repositories.ServerRepository) -} - -func (suite *Suite) SetupTest() { - suite.Suite.SetupTest() -} diff --git a/test/functional/repositoriestest/suite.go b/test/functional/repositoriestest/suite.go deleted file mode 100644 index 3256f98..0000000 --- a/test/functional/repositoriestest/suite.go +++ /dev/null @@ -1,221 +0,0 @@ -package repositoriestest - -import ( - "context" - "encoding/json" - "io" - "net/http" - "sync" - "time" - - "github.com/gameap/daemon/internal/app/config" - "github.com/gameap/daemon/internal/app/di" - "github.com/gorilla/mux" - "github.com/pkg/errors" - "github.com/sirupsen/logrus/hooks/test" - "github.com/stretchr/testify/suite" -) - -type apiResponse struct { - StatusCode int - Body []byte -} - -type apiResponses []apiResponse - -type apiRequests [][]byte - -type Suite struct { - suite.Suite - - Cfg *config.Config - Container *di.Container - - apiResponses map[string]apiResponses - apiServer *http.Server - wg *sync.WaitGroup - - apiPutCalled map[string]apiRequests - apiPostCalled map[string]apiRequests -} - -func (suite *Suite) GivenAPIResponse(path string, status int, body []byte) { - r := apiResponse{status, body} - - if _, ok := suite.apiResponses[path]; !ok { - suite.apiResponses[path] = apiResponses{r} - } else { - suite.apiResponses[path] = append(suite.apiResponses[path], r) - } -} - -func (suite *Suite) SetupSuite() { - suite.apiPutCalled = map[string]apiRequests{} - suite.apiPostCalled = map[string]apiRequests{} - - suite.apiResponses = map[string]apiResponses{} - - suite.wg = &sync.WaitGroup{} - - suite.Cfg = &config.Config{ - APIHost: "http://localhost:14323", - APIKey: "0oKyfcfjZOycicaazEgW6sHw9cYUMJDVJl0pXKjMYu44eoBWBwvXUJZdv6z6OfKs", - - LogLevel: "trace", - } - - getTokenJSON, err := json.Marshal(struct { - Token string `json:"token"` - TimeStamp int64 `json:"timestamp"` - }{ - "dYCw9ADVnS03leY9dLlckgaxiG59uKF3KMCcpmXpJUKYmlQXuAhvHtCYbL6hG3Ce", - time.Now().Unix(), - }) - if err != nil { - suite.T().Fatal(err) - } - - suite.setupAPIServer() - suite.GivenAPIResponse("/gdaemon_api/get_token", http.StatusOK, getTokenJSON) - - log, _ := test.NewNullLogger() - container, err := di.NewContainer( - suite.Cfg, - log, - ) - if err != nil { - suite.T().Fatal(err) - } - - suite.Container = container -} - -func (suite *Suite) TearDownSuite() { - err := suite.apiServer.Shutdown(context.Background()) - if err != nil { - suite.T().Fatal(err) - } - - suite.wg.Wait() -} - -func (suite *Suite) SetupTest() { - suite.apiResponses = map[string]apiResponses{} -} - -func (suite *Suite) setupAPIServer() { - suite.apiServer = &http.Server{Addr: ":14323"} - - router := mux.NewRouter() - router.PathPrefix("/"). - Methods(http.MethodGet, http.MethodPost, http.MethodPut). - HandlerFunc(suite.apiTestServerHandler) - - http.Handle("/", router) - - suite.wg.Add(1) - go func() { - defer suite.wg.Done() - - if err := suite.apiServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { - panic(err) - } - }() -} - -func (suite *Suite) apiTestServerHandler(writer http.ResponseWriter, request *http.Request) { - responses, exist := suite.apiResponses[request.RequestURI] - if !exist || len(responses) == 0 { - writer.WriteHeader(http.StatusNotFound) - return - } - - if request.Method == http.MethodPut { - body, err := io.ReadAll(request.Body) - if err != nil { - suite.T().Fatal(err) - } - - suite.appendPutCall(request.RequestURI, body) - } else if request.Method == http.MethodPost { - body, err := io.ReadAll(request.Body) - if err != nil { - suite.T().Fatal(err) - } - - suite.appendPostCall(request.RequestURI, body) - } - - response := responses[0] - suite.apiResponses[request.RequestURI] = suite.apiResponses[request.RequestURI][1:] - - writer.WriteHeader(response.StatusCode) - _, _ = writer.Write(response.Body) -} - -func (suite *Suite) appendPutCall(uri string, body []byte) { - _, exist := suite.apiPutCalled[uri] - - if exist { - suite.apiPutCalled[uri] = append(suite.apiPutCalled[uri], body) - } else { - suite.apiPutCalled[uri] = [][]byte{body} - } -} - -func (suite *Suite) appendPostCall(uri string, body []byte) { - _, exist := suite.apiPostCalled[uri] - - if exist { - suite.apiPostCalled[uri] = append(suite.apiPostCalled[uri], body) - } else { - suite.apiPostCalled[uri] = [][]byte{body} - } -} - -func (suite *Suite) AssertAPIPutCalled(url string, body []byte) { - suite.T().Helper() - - suite.AssertAPICalled(http.MethodPut, url, body) -} - -func (suite *Suite) AssertAPIPostCalled(url string, body []byte) { - suite.T().Helper() - - suite.AssertAPICalled(http.MethodPost, url, body) -} - -func (suite *Suite) AssertAPICalled(method string, url string, body []byte) { - suite.T().Helper() - - var urlCalled apiRequests - var isCalled bool - - switch method { - case http.MethodPost: - urlCalled, isCalled = suite.apiPostCalled[url] - case http.MethodPut: - urlCalled, isCalled = suite.apiPutCalled[url] - default: - suite.T().Fatal("Unsupported http method to assert") - } - - if !isCalled { - suite.T().Errorf("api call not found (%s)", url) - return - } - - equalFound := false - for _, v := range urlCalled { - if suite.JSONEq(string(v), string(body)) { - equalFound = true - } - } - - if !equalFound { - suite.T().Errorf("api call not found (%s)\n"+ - "found: \n%s", - url, urlCalled, - ) - } -} diff --git a/test/functional/server_tasks/scheduler_grpc_test.go b/test/functional/server_tasks/scheduler_grpc_test.go new file mode 100644 index 0000000..8154929 --- /dev/null +++ b/test/functional/server_tasks/scheduler_grpc_test.go @@ -0,0 +1,209 @@ +package server_tasks_test + +import ( + "context" + "strconv" + "sync" + "testing" + "time" + + "github.com/gameap/daemon/internal/app/contracts" + "github.com/gameap/daemon/internal/app/domain" + gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" + serversscheduler "github.com/gameap/daemon/internal/app/servers_scheduler" + pb "github.com/gameap/gameap/pkg/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// End-to-end smoke test: drive the scheduler via its gRPC-facing public API +// only (ApplySnapshot / ApplyDelta / CancelExecution + the ServerTaskSender +// the GatewayClient implements). Verifies the messaging contract the daemon +// emits in response to inputs the panel would push over the bidi stream. +func TestServerTaskScheduler_PublicAPI_SnapshotToFinished(t *testing.T) { + server := newServer(42) + repo := &serverRepoStub{servers: map[int]*domain.Server{server.ID(): server}} + sender := newSender() + loader := &loaderStub{cmd: &cmdStub{output: []byte("done")}} + + scheduler := serversscheduler.NewScheduler(nil, loader, repo, sender) + + now := time.Now() + scheduler.ApplySnapshot(&pb.ServerTaskSnapshot{ + Tasks: []*pb.ServerTask{{ + Id: 1, + ServerId: 42, + Version: 1, + Command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_RESTART, + ExecuteDate: timestamppb.New(now.Add(-30 * time.Second)), + RepeatPeriod: durationpb.New(time.Hour), + Enabled: true, + OverlapPolicy: pb.ServerTaskOverlapPolicy_SERVER_TASK_OVERLAP_POLICY_SKIP, + CatchupPolicy: pb.ServerTaskCatchupPolicy_SERVER_TASK_CATCHUP_POLICY_SKIP, + }}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { _ = scheduler.Run(ctx) }() + + finished := waitForFinishedExternal(t, sender, 1, 15*time.Second) + require.Len(t, finished, 1) + assert.Equal(t, pb.ServerTaskExecutionStatus_SERVER_TASK_EXECUTION_STATUS_SUCCESS, finished[0].Status) + assert.Equal(t, []byte("done"), finished[0].OutputInline) + + started := sender.snapshot().started + require.Len(t, started, 1) + assert.Equal(t, started[0].ExecutionId, finished[0].ExecutionId) +} + +func TestServerTaskScheduler_PublicAPI_DeleteThenNoFire(t *testing.T) { + server := newServer(42) + repo := &serverRepoStub{servers: map[int]*domain.Server{server.ID(): server}} + sender := newSender() + loader := &loaderStub{cmd: &cmdStub{}} + + scheduler := serversscheduler.NewScheduler(nil, loader, repo, sender) + + now := time.Now() + scheduler.ApplySnapshot(&pb.ServerTaskSnapshot{ + Tasks: []*pb.ServerTask{{ + Id: 2, + ServerId: 42, + Version: 1, + Command: pb.ServerTaskCommand_SERVER_TASK_COMMAND_RESTART, + ExecuteDate: timestamppb.New(now.Add(time.Hour)), + RepeatPeriod: durationpb.New(time.Hour), + Enabled: true, + OverlapPolicy: pb.ServerTaskOverlapPolicy_SERVER_TASK_OVERLAP_POLICY_SKIP, + CatchupPolicy: pb.ServerTaskCatchupPolicy_SERVER_TASK_CATCHUP_POLICY_SKIP, + }}, + }) + + scheduler.ApplyDelta(&pb.ServerTaskDelta{ + Kind: &pb.ServerTaskDelta_Deleted{ + Deleted: &pb.ServerTaskDeleted{Id: 2, Version: 2}, + }, + }) + + assert.Empty(t, sender.snapshot().started, "deleted task must not fire") + assert.Equal(t, 0, loader.Calls()) +} + +// --- minimal test doubles (kept local to this _test package) --- + +type sentSnapshot struct { + started []*pb.ServerTaskExecutionStarted + finished []*pb.ServerTaskExecutionFinished + logs []*pb.ServerTaskExecutionLog + resync []*pb.ServerTaskResyncRequest +} + +type sender struct { + mu sync.Mutex + all []*pb.DaemonMessage +} + +func newSender() *sender { return &sender{} } + +func (s *sender) Send(m *pb.DaemonMessage) { + s.mu.Lock() + defer s.mu.Unlock() + s.all = append(s.all, m) +} + +func (s *sender) snapshot() sentSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + + var out sentSnapshot + for _, m := range s.all { + switch { + case m.GetServerTaskExecutionStarted() != nil: + out.started = append(out.started, m.GetServerTaskExecutionStarted()) + case m.GetServerTaskExecutionFinished() != nil: + out.finished = append(out.finished, m.GetServerTaskExecutionFinished()) + case m.GetServerTaskExecutionLog() != nil: + out.logs = append(out.logs, m.GetServerTaskExecutionLog()) + case m.GetServerTaskResyncRequest() != nil: + out.resync = append(out.resync, m.GetServerTaskResyncRequest()) + } + } + return out +} + +type serverRepoStub struct { + mu sync.Mutex + servers map[int]*domain.Server +} + +func (r *serverRepoStub) FindByID(_ context.Context, id int) (*domain.Server, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.servers[id], nil +} + +func (r *serverRepoStub) Save(_ context.Context, _ *domain.Server) error { return nil } + +func (r *serverRepoStub) IDs(_ context.Context) ([]int, error) { return nil, nil } + +type cmdStub struct { + output []byte +} + +func (c *cmdStub) Execute(_ context.Context, _ *domain.Server) error { return nil } +func (c *cmdStub) Result() int { return gameservercommands.SuccessResult } +func (c *cmdStub) IsComplete() bool { return true } +func (c *cmdStub) ReadOutput() []byte { return c.output } + +type loaderStub struct { + mu sync.Mutex + cmd *cmdStub + calls int +} + +func (l *loaderStub) LoadServerCommand(_ domain.ServerCommand, _ *domain.Server) contracts.GameServerCommand { + l.mu.Lock() + defer l.mu.Unlock() + l.calls++ + return l.cmd +} + +func (l *loaderStub) Calls() int { + l.mu.Lock() + defer l.mu.Unlock() + return l.calls +} + +func newServer(id int) *domain.Server { + return domain.NewServer( + id, true, domain.ServerInstalled, false, + "server-"+strconv.Itoa(id), + "uuid-"+strconv.Itoa(id), + "short-"+strconv.Itoa(id), + domain.Game{}, domain.GameMod{}, + "127.0.0.1", 25565, 25565, 25565, + "", "/srv/test/"+strconv.Itoa(id), + "", "", "", "", "", + false, time.Unix(0, 0), + map[string]string{}, domain.Settings{}, + time.Unix(0, 0), 0, 0, + ) +} + +func waitForFinishedExternal(t *testing.T, s *sender, want int, max time.Duration) []*pb.ServerTaskExecutionFinished { + t.Helper() + deadline := time.Now().Add(max) + for time.Now().Before(deadline) { + fin := s.snapshot().finished + if len(fin) >= want { + return fin + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("expected %d Finished events; got %d", want, len(s.snapshot().finished)) + return nil +} diff --git a/test/functional/server_tasks/server_tasks_test.go b/test/functional/server_tasks/server_tasks_test.go deleted file mode 100644 index 0be1895..0000000 --- a/test/functional/server_tasks/server_tasks_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package servertasks - -import ( - "context" - "time" -) - -func (suite *Suite) TestScheduler_ExpectTaskExecutedAndUpdated() { - executeDate := time.Now().Add(4 * time.Second) - task := suite.GivenTask(1, executeDate, 10*time.Minute) - - suite.RunServerSchedulerUntilTaskCounterIncreased(task) - - task, _ = suite.ServerTaskRepository.FindByID(context.Background(), 1) - suite.Assert().Equal(1, task.ID()) - suite.Assert().Equal(1, task.Counter()) - suite.Assert().Equal(0, task.Repeat()) - suite.Assert().Equal(executeDate.Add(10*time.Minute), task.ExecuteDate()) -} - -func (suite *Suite) TestScheduler_ExpectTaskDidNotExecuteAndUpdated() { - executeDate := time.Now().Add(10 * time.Minute) - task := suite.GivenTask(2, executeDate, 10*time.Minute) - - suite.RunServerSchedulerUntilTaskCounterIncreased(task) - - task, _ = suite.ServerTaskRepository.FindByID(context.Background(), 2) - suite.Assert().Equal(2, task.ID()) - suite.Assert().Equal(0, task.Counter()) - suite.Assert().Equal(0, task.Repeat()) - suite.Assert().Equal(executeDate, task.ExecuteDate()) -} diff --git a/test/functional/server_tasks/suite_test.go b/test/functional/server_tasks/suite_test.go deleted file mode 100644 index 5b41707..0000000 --- a/test/functional/server_tasks/suite_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package servertasks - -import ( - "context" - "os" - "testing" - "time" - - "github.com/gameap/daemon/internal/app/components" - "github.com/gameap/daemon/internal/app/config" - "github.com/gameap/daemon/internal/app/contracts" - "github.com/gameap/daemon/internal/app/domain" - "github.com/gameap/daemon/internal/app/fsutil" - gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" - serversscheduler "github.com/gameap/daemon/internal/app/servers_scheduler" - "github.com/gameap/daemon/internal/processmanager" - "github.com/gameap/daemon/test/functional" - "github.com/gameap/daemon/test/mocks" - "github.com/stretchr/testify/suite" -) - -type Suite struct { - functional.GameServerSuite - - Scheduler *serversscheduler.Scheduler - ServerTaskRepository *mocks.ServerTaskRepository - ServerRepository *mocks.ServerRepository - Executor contracts.Executor - ProcessManager contracts.ProcessManager - Cfg *config.Config - - WorkPath string -} - -func TestSuite(t *testing.T) { - suite.Run(t, new(Suite)) -} - -func (suite *Suite) SetupSuite() { - suite.Cfg = &config.Config{ - Scripts: config.Scripts{ - Start: "{command}", - Stop: "{command}", - }, - } - - suite.ServerRepository = mocks.NewServerRepository() - suite.ServerTaskRepository = mocks.NewServerTaskRepository() - suite.Executor = components.NewExecutor() - suite.ProcessManager = processmanager.NewSimple(suite.Cfg, suite.Executor, suite.Executor) -} - -func (suite *Suite) SetupTest() { - var err error - - suite.ServerRepository.Clear() - suite.ServerTaskRepository.Clear() - - suite.Scheduler = serversscheduler.NewScheduler( - suite.Cfg, - suite.ServerTaskRepository, - gameservercommands.NewFactory( - suite.Cfg, - suite.ServerRepository, - suite.Executor, - suite.ProcessManager, - ), - ) - - suite.WorkPath, err = os.MkdirTemp(os.TempDir(), "gameap-daemon-test") - if err != nil { - suite.T().Fatal(err) - } - - err = os.MkdirAll(suite.WorkPath+"/server", 0777) - if err != nil { - suite.T().Fatal(err) - } - - err = fsutil.Copy("../../servers/scripts", suite.WorkPath+"/server", fsutil.CopyOptions{}) - if err != nil { - suite.T().Fatal(err) - } - - suite.Cfg.WorkPath = suite.WorkPath -} - -func (suite *Suite) GivenTask(id int, executeDate time.Time, repeatPeriod time.Duration) *domain.ServerTask { - server := suite.GivenServerWithStartCommand("./make_file_with_contents.sh") - task := domain.NewServerTask( - id, - domain.ServerTaskStart, - server, - 0, - repeatPeriod, - 0, - executeDate, - ) - - suite.ServerRepository.Set([]*domain.Server{server}) - suite.ServerTaskRepository.Set([]*domain.ServerTask{task}) - - return task -} - -func (suite *Suite) TearDownTest() { - err := os.RemoveAll(suite.WorkPath) - if err != nil { - suite.T().Log(err) - } -} - -func (suite *Suite) RunServerSchedulerWithTimeout(duration time.Duration) { - suite.T().Helper() - - ctx, cancel := context.WithTimeout(context.Background(), duration) - defer cancel() - - err := suite.Scheduler.Run(ctx) - - suite.Require().NoError(err) -} - -func (suite *Suite) RunServerSchedulerUntilTaskCounterIncreased(task *domain.ServerTask) { - initTaskCounter := task.Counter() - startedAt := time.Now() - - ctx, cancel := context.WithCancel(context.Background()) - - go func(t *testing.T) { - t.Helper() - - err := suite.Scheduler.Run(ctx) - if err != nil { - t.Log(err) - } - }(suite.T()) - - for { - if time.Since(startedAt) >= 10*time.Second { - cancel() - break - } - - if task.Counter() > initTaskCounter { - cancel() - break - } - } - - time.Sleep(1 * time.Second) -} diff --git a/test/functional/servertest/commands/commands_unix_test.go b/test/functional/servertest/commands/commands_unix_test.go deleted file mode 100644 index 46d0e7a..0000000 --- a/test/functional/servertest/commands/commands_unix_test.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows && !plan9 -// +build !windows,!plan9 - -package commands - -const ( - echoTestStringCmd = "echo -n \"test string\"" - falseCmd = "false" -) diff --git a/test/functional/servertest/commands/commands_windows_test.go b/test/functional/servertest/commands/commands_windows_test.go deleted file mode 100644 index 6812fbf..0000000 --- a/test/functional/servertest/commands/commands_windows_test.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build windows -// +build windows - -package commands - -const ( - echoTestStringCmd = "powershell Write-Host \"test string\" -nonewline" - falseCmd = "powershell exit 1" -) diff --git a/test/functional/servertest/commands/exec_test.go b/test/functional/servertest/commands/exec_test.go deleted file mode 100644 index 964418e..0000000 --- a/test/functional/servertest/commands/exec_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package commands - -import ( - "github.com/et-nik/binngo" - "github.com/et-nik/binngo/decode" - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/stretchr/testify/assert" -) - -func (suite *Suite) TestAuth() { - msg, err := binngo.Marshal([]interface{}{0, "login", "password", server.ModeCommands}) - if err != nil { - suite.T().Fatal(err) - } - suite.Suite.ClientWrite(msg) - buf := make([]byte, 256) - suite.Suite.ClientRead(buf) - var r response.Response - - err = decode.Unmarshal(buf, &r) - - if assert.NoError(suite.T(), err) { - assert.Equal(suite.T(), response.StatusOK, r.Code) - assert.Equal(suite.T(), "Auth success", r.Info) - } -} - -func (suite *Suite) TestExecSuccess() { - suite.Auth(server.ModeCommands) - msg, err := binngo.Marshal([]interface{}{1, echoTestStringCmd, "/"}) - if err != nil { - suite.T().Fatal(err) - } - suite.ClientWrite(msg) - buf := make([]byte, 256) - suite.ClientRead(buf) - var r []interface{} - - err = decode.Unmarshal(buf, &r) - - if assert.NoError(suite.T(), err) { - assert.Equal(suite.T(), response.StatusOK, response.Code(r[0].(uint8))) - assert.Equal(suite.T(), int8(0), r[1]) - assert.Equal(suite.T(), "test string", r[2]) - } -} - -func (suite *Suite) TestExecErrorCode() { - suite.Auth(server.ModeCommands) - msg, err := binngo.Marshal([]interface{}{1, falseCmd, "/"}) - if err != nil { - suite.T().Fatal(err) - } - suite.ClientWrite(msg) - buf := make([]byte, 256) - suite.ClientRead(buf) - var r []interface{} - - err = decode.Unmarshal(buf, &r) - - suite.Require().NoError(err) - suite.Assert().Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.Assert().Equal(uint8(1), r[1]) - suite.Assert().Equal("", r[2]) -} - -func (suite *Suite) TestInvalidCommand() { - suite.Auth(server.ModeCommands) - msg, err := binngo.Marshal([]interface{}{1, "invalid command", "/"}) - if err != nil { - suite.T().Fatal(err) - } - suite.ClientWrite(msg) - buf := make([]byte, 256) - suite.ClientRead(buf) - var r []interface{} - - err = decode.Unmarshal(buf, &r) - - suite.Require().NoError(err) - suite.Equal(response.StatusError, response.Code(r[0].(uint8))) - suite.Contains(r[1], "executable file not found") -} - -func (suite *Suite) TestInvalidWorkDir() { - suite.Auth(server.ModeCommands) - msg, err := binngo.Marshal([]interface{}{1, "echo hello", "/invalid-path"}) - if err != nil { - suite.T().Fatal(err) - } - suite.ClientWrite(msg) - buf := make([]byte, 256) - suite.ClientRead(buf) - var r []interface{} - - err = decode.Unmarshal(buf, &r) - - suite.Require().NoError(err) - suite.Equal(response.StatusError, response.Code(r[0].(uint8))) - suite.Contains(r[1], "invalid work directory") -} - -func (suite *Suite) TestInvalidMessage() { - suite.Auth(server.ModeCommands) - msg, err := binngo.Marshal(struct { - invalid string - }{ - invalid: "echo hello", - }) - if err != nil { - suite.T().Fatal(err) - } - suite.ClientWrite(msg) - buf := make([]byte, 256) - suite.ClientRead(buf) - var r []interface{} - - err = decode.Unmarshal(buf, &r) - - suite.Require().NoError(err) - suite.Equal(response.StatusError, response.Code(r[0].(uint8))) - suite.Equal("Failed to decode message", r[1]) -} diff --git a/test/functional/servertest/commands/suite_test.go b/test/functional/servertest/commands/suite_test.go deleted file mode 100644 index df304cc..0000000 --- a/test/functional/servertest/commands/suite_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package commands - -import ( - "testing" - - "github.com/gameap/daemon/test/functional/servertest" - "github.com/stretchr/testify/suite" -) - -type Suite struct { - servertest.Suite -} - -func TestSuite(t *testing.T) { - suite.Run(t, new(Suite)) -} diff --git a/test/functional/servertest/files/chmod_test.go b/test/functional/servertest/files/chmod_test.go deleted file mode 100644 index f32ff87..0000000 --- a/test/functional/servertest/files/chmod_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package files - -import ( - "os" - "runtime" - "testing" - - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/files" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/stretchr/testify/assert" -) - -func (suite *Suite) TestChmodSuccess() { - suite.Auth(server.ModeFiles) - rel, abs := suite.workFile("chmod", []byte("x")) - - var tests []struct { - name string - perm uint16 - expected string - } - //nolint:goconst - if runtime.GOOS == "windows" { - tests = []struct { - name string - perm uint16 - expected string - }{ - {"only owner read write", 0600, "-rw-rw-rw-"}, - {"only owner read", 0400, "-r--r--r--"}, - {"all read write", 0666, "-rw-rw-rw-"}, - } - } else { - tests = []struct { - name string - perm uint16 - expected string - }{ - {"only owner read write", 0600, "-rw-------"}, - {"only owner read", 0400, "-r--------"}, - {"all read write", 0666, "-rw-rw-rw-"}, - {"all read write execute", 0777, "-rwxrwxrwx"}, - } - } - - for _, test := range tests { - suite.T().Run(test.name, func(t *testing.T) { - msg := []interface{}{files.FileChmod, rel, test.perm} - - r := suite.ClientWriteReadAndDecodeList(msg) - - assert.Equal(t, response.StatusOK, response.Code(r[0].(uint8))) - stat, err := os.Stat(abs) - if err != nil { - t.Fatal(err) - } - assert.Equal(t, test.expected, stat.Mode().String()) - }) - } -} diff --git a/test/functional/servertest/files/file_info_test.go b/test/functional/servertest/files/file_info_test.go deleted file mode 100644 index ca64453..0000000 --- a/test/functional/servertest/files/file_info_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package files - -import ( - "os" - - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/files" - "github.com/gameap/daemon/internal/app/server/response" -) - -func (suite *Suite) TestTextFileInfoSuccess() { - suite.Auth(server.ModeFiles) - err := os.Chmod(suite.fixtureAbs("file.txt"), 0664) - if err != nil { - suite.T().Fatal(err) - } - msg := []interface{}{files.FileInfo, fixturesRel + "/file.txt"} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - fInfo := r[2].([]interface{}) - suite.assertFileDetails( - fInfo, - "file.txt", - 9, - files.TypeFile, - 0664, - "text/plain; charset=utf-8", - ) -} - -func (suite *Suite) TestJsonFileInfoSuccess() { - suite.Auth(server.ModeFiles) - err := os.Chmod(suite.fixtureAbs("file.json"), 0664) - if err != nil { - suite.T().Fatal(err) - } - msg := []interface{}{files.FileInfo, fixturesRel + "/file.json"} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - fInfo := r[2].([]interface{}) - suite.assertFileDetails( - fInfo, - "file.json", - 66, - files.TypeFile, - 0664, - "application/json", - ) -} - -func (suite *Suite) TestDirectoryInfoSuccess() { - suite.Auth(server.ModeFiles) - err := os.Chmod(suite.fixtureAbs("directory"), 0775) - if err != nil { - suite.T().Fatal(err) - } - msg := []interface{}{files.FileInfo, fixturesRel + "/directory"} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - fInfo := r[2].([]interface{}) - suite.assertFileDetails( - fInfo, - "directory", - 0, - files.TypeDir, - 0775, - "", - ) -} - -func (suite *Suite) TestSymlinkInfoSuccess() { - suite.Auth(server.ModeFiles) - err := os.Chmod(suite.fixtureAbs("symlink_to_file_txt"), 0777) - if err != nil { - suite.T().Fatal(err) - } - msg := []interface{}{files.FileInfo, fixturesRel + "/symlink_to_file_txt"} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - fInfo := r[2].([]interface{}) - suite.assertFileDetails( - fInfo, - "symlink_to_file_txt", - 10, - files.TypeSymlink, - 0777, - "", - ) -} - -func (suite *Suite) TestFileInfo_EmptyFile_Success() { - suite.Authenticate() - err := os.Chmod(suite.fixtureAbs("empty_file.txt"), 0664) - if err != nil { - suite.T().Fatal(err) - } - msg := []interface{}{files.FileInfo, fixturesRel + "/empty_file.txt"} - - r := suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - fInfo, ok := r[2].([]interface{}) - suite.Require().True(ok) - suite.assertFileDetails( - fInfo, - "empty_file.txt", - 0, - files.TypeFile, - 0664, - "", - ) -} diff --git a/test/functional/servertest/files/list_test.go b/test/functional/servertest/files/list_test.go deleted file mode 100644 index c6f4c53..0000000 --- a/test/functional/servertest/files/list_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package files - -import ( - "os" - "runtime" - - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/files" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/stretchr/testify/assert" -) - -func (suite *Suite) TestListSuccess() { - suite.Auth(server.ModeFiles) - err := os.Chmod(suite.fixtureAbs("file.txt"), 0664) - if err != nil { - suite.T().Fatal(err) - } - msg := []interface{}{files.ReadDir, fixturesRel, files.ListWithDetails} - - r := suite.ClientWriteReadAndDecodeList(msg) - - assert.Equal(suite.T(), response.StatusOK, response.Code(r[0].(uint8))) - fList := r[2].([]interface{}) - var fileTxtInfo []interface{} - for _, item := range fList { - fInfo, ok := item.([]interface{}) - if !ok { - suite.T().Fatal("Invalid item") - } - - if fInfo[0] == "file.txt" { - fileTxtInfo = fInfo - } - } - if fileTxtInfo == nil { - suite.T().Fatal("file.txt not found") - } - assert.Equal(suite.T(), "file.txt", fileTxtInfo[0]) - assert.Equal(suite.T(), uint8(9), fileTxtInfo[1]) - assert.Equal(suite.T(), uint8(2), fileTxtInfo[3]) - if runtime.GOOS != "windows" { - assert.Equal(suite.T(), uint16(0664), fileTxtInfo[4]) - } -} - -func (suite *Suite) TestListNotExistenceDirectory() { - suite.Auth(server.ModeFiles) - msg := []interface{}{files.ReadDir, fixturesRel + "/not-existence", files.ListWithDetails} - - r := suite.ClientWriteReadAndDecodeList(msg) - - assert.Equal(suite.T(), response.StatusError, response.Code(r[0].(uint8))) - assert.Equal(suite.T(), "Directory does not exist", r[1].(string)) -} diff --git a/test/functional/servertest/files/mkdir_test.go b/test/functional/servertest/files/mkdir_test.go deleted file mode 100644 index 6843c35..0000000 --- a/test/functional/servertest/files/mkdir_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package files - -import ( - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/files" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/stretchr/testify/assert" -) - -func (suite *Suite) TestMakeDirSuccess() { - suite.Auth(server.ModeFiles) - rel, abs := suite.workPath("mkdir") - msg := []interface{}{files.MakeDir, rel} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.DirExists(abs) -} - -func (suite *Suite) TestMakeDir_WhenThreeMessage_ExpectSuccess() { - suite.Auth(server.ModeFiles) - rel1, abs1 := suite.workPath("mkdir") - rel2, abs2 := suite.workPath("mkdir") - rel3, abs3 := suite.workPath("mkdir") - msg1 := []interface{}{files.MakeDir, rel1} - msg2 := []interface{}{files.MakeDir, rel2} - msg3 := []interface{}{files.MakeDir, rel3} - - r1 := suite.ClientWriteReadAndDecodeList(msg1) - r2 := suite.ClientWriteReadAndDecodeList(msg2) - r3 := suite.ClientWriteReadAndDecodeList(msg3) - - suite.Equal(response.StatusOK, response.Code(r1[0].(uint8))) - suite.DirExists(abs1) - suite.Equal(response.StatusOK, response.Code(r2[0].(uint8))) - suite.DirExists(abs2) - suite.Equal(response.StatusOK, response.Code(r3[0].(uint8))) - suite.DirExists(abs3) -} - -func (suite *Suite) TestMakeDirInvalidMessage() { - suite.Auth(server.ModeFiles) - msg := []interface{}{files.MakeDir, 122, "invalid"} - - r := suite.ClientWriteReadAndDecodeList(msg) - - assert.Equal(suite.T(), response.StatusError, response.Code(r[0].(uint8))) - assert.Equal(suite.T(), "Invalid message", r[1]) -} diff --git a/test/functional/servertest/files/move_copy_test.go b/test/functional/servertest/files/move_copy_test.go deleted file mode 100644 index 06c3594..0000000 --- a/test/functional/servertest/files/move_copy_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package files - -import ( - "io/fs" - "os" - "path/filepath" - "runtime" - - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/files" - "github.com/gameap/daemon/internal/app/server/response" -) - -func (suite *Suite) TestMoveFileSuccess() { - suite.Auth(server.ModeFiles) - srcRel, srcAbs := suite.workFile("move", []byte("data")) - dstRel, dstAbs := suite.workPath("moved") - msg := []interface{}{files.FileMove, srcRel, dstRel, false} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.FileExists(dstAbs) - suite.NoFileExists(srcAbs) -} - -func (suite *Suite) TestCopyFileSuccess() { - suite.Auth(server.ModeFiles) - srcRel, srcAbs := suite.workFile("copy", []byte("data")) - dstRel, dstAbs := suite.workPath("copied") - msg := []interface{}{files.FileMove, srcRel, dstRel, true} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.FileExists(dstAbs) - suite.FileExists(srcAbs) -} - -func (suite *Suite) TestCopyRelativePathSuccess() { - suite.Auth(server.ModeFiles) - dstRel, dstAbs := suite.workPath("copytree") - msg := []interface{}{files.FileMove, fixturesRel, dstRel, true} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.DirExists(filepath.Join(dstAbs, "directory")) - suite.FileExists(filepath.Join(dstAbs, "file.json")) - suite.FileExists(filepath.Join(dstAbs, "file.txt")) - if runtime.GOOS != "windows" && suite.FileExists(filepath.Join(dstAbs, "symlink_to_file_txt")) { - s, err := os.Lstat(filepath.Join(dstAbs, "symlink_to_file_txt")) - if err != nil { - suite.T().Fatal(err) - } - suite.True(s.Mode()&fs.ModeSymlink != 0) - } -} - -func (suite *Suite) TestCopyDirectorySuccess() { - suite.Auth(server.ModeFiles) - srcRel, srcAbs := suite.workDir("src") - if err := os.WriteFile(filepath.Join(srcAbs, "f.bin"), []byte("z"), 0o644); err != nil { - suite.T().Fatal(err) - } - dstRel, dstAbs := suite.workPath("dst") - msg := []interface{}{files.FileMove, srcRel, dstRel, true} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.FileExists(filepath.Join(dstAbs, "f.bin")) - suite.FileExists(filepath.Join(srcAbs, "f.bin")) -} - -func (suite *Suite) TestMoveDirectorySuccess() { - suite.Auth(server.ModeFiles) - srcRel, srcAbs := suite.workDir("src") - if err := os.WriteFile(filepath.Join(srcAbs, "f.bin"), []byte("z"), 0o644); err != nil { - suite.T().Fatal(err) - } - dstRel, dstAbs := suite.workPath("dst") - msg := []interface{}{files.FileMove, srcRel, dstRel, false} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.NoDirExists(srcAbs) - suite.FileExists(filepath.Join(dstAbs, "f.bin")) -} - -func (suite *Suite) TestMoveInvalidSource() { - suite.Auth(server.ModeFiles) - msg := []interface{}{files.FileMove, "/invalid-source", "/invalid-destination", false} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusError, response.Code(r[0].(uint8))) - suite.Equal("Source \"/invalid-source\" not found", r[1].(string)) -} - -func (suite *Suite) TestMoveInvalidDestination() { - suite.Auth(server.ModeFiles) - srcRel, _ := suite.workDir("src") - dstRel, _ := suite.workDir("dst") - msg := []interface{}{files.FileMove, srcRel, dstRel, false} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusError, response.Code(r[0].(uint8))) - suite.Equal("Destination \""+dstRel+"\" already exists", r[1].(string)) -} - -func (suite *Suite) TestMoveInvalidMessage() { - suite.Auth(server.ModeFiles) - msg := []interface{}{files.FileMove, 0xFF} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusError, response.Code(r[0].(uint8))) - suite.Equal("Invalid message", r[1].(string)) -} diff --git a/test/functional/servertest/files/remove_test.go b/test/functional/servertest/files/remove_test.go deleted file mode 100644 index a8d5e50..0000000 --- a/test/functional/servertest/files/remove_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package files - -import ( - "os" - "path/filepath" - - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/files" - "github.com/gameap/daemon/internal/app/server/response" -) - -func (suite *Suite) TestRemoveFileSuccess() { - suite.Auth(server.ModeFiles) - rel, abs := suite.workFile("rm", []byte("x")) - msg := []interface{}{files.FileRemove, rel, false} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.NoFileExists(abs) -} - -func (suite *Suite) TestRemoveEmptyDirSuccess() { - suite.Auth(server.ModeFiles) - rel, abs := suite.workDir("rmdir") - msg := []interface{}{files.FileRemove, rel, false} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.NoDirExists(abs) -} - -func (suite *Suite) TestRemoveNotEmptyDirFail() { - suite.Auth(server.ModeFiles) - rel, abs := suite.workDir("rmdir") - if err := os.MkdirAll(filepath.Join(abs, "inner_dir"), 0o755); err != nil { - suite.T().Fatal(err) - } - msg := []interface{}{files.FileRemove, rel, false} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusError, response.Code(r[0].(uint8))) - suite.Equal("Failed to remove", r[1].(string)) - suite.DirExists(abs) -} - -func (suite *Suite) TestRemoveRecursiveNotEmptyDirSuccess() { - suite.Auth(server.ModeFiles) - rel, abs := suite.workDir("rmdir") - if err := os.MkdirAll(filepath.Join(abs, "inner_dir"), 0o755); err != nil { - suite.T().Fatal(err) - } - msg := []interface{}{files.FileRemove, rel, true} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.NoDirExists(abs) -} - -func (suite *Suite) TestNotExistFileFail() { - suite.Auth(server.ModeFiles) - msg := []interface{}{files.FileRemove, "/invalid-path", true} - - r := suite.ClientWriteReadAndDecodeList(msg) - - suite.Equal(response.StatusError, response.Code(r[0].(uint8))) - suite.Equal("Path not exist", r[1].(string)) -} diff --git a/test/functional/servertest/files/suite_test.go b/test/functional/servertest/files/suite_test.go deleted file mode 100644 index 9b4abe6..0000000 --- a/test/functional/servertest/files/suite_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package files - -import ( - "os" - "path/filepath" - "runtime" - "strconv" - "testing" - - "github.com/et-nik/binngo/decode" - "github.com/gameap/daemon/internal/app/fsutil" - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/files" - "github.com/gameap/daemon/test/functional/servertest" - "github.com/stretchr/testify/suite" -) - -// The legacy TCP file handler is now jailed to the daemon work directory -// (servertest.Suite.WorkPath). Every path sent to the daemon is therefore -// resolved relative to WorkPath, so the fixtures these tests operate on are -// mirrored into WorkPath/testfiles and the helpers below hand out -// (relative-to-WorkPath, absolute-on-disk) path pairs. -const fixturesRel = "testfiles" - -type Suite struct { - servertest.Suite - - seq int - - relFileDestination string - tempFileDestination string -} - -func TestSuite(t *testing.T) { - suite.Run(t, new(Suite)) -} - -func (suite *Suite) SetupSuite() { - suite.Suite.SetupSuite() - - err := fsutil.Copy( - "../../../../test/files", - filepath.Join(suite.WorkPath, fixturesRel), - fsutil.CopyOptions{Symlink: fsutil.SymlinkShallow}, - ) - if err != nil { - suite.T().Fatal(err) - } -} - -func (suite *Suite) SetupTest() { - suite.Suite.SetupTest() - - suite.relFileDestination = "upload/file" - suite.tempFileDestination = filepath.Join(suite.WorkPath, "upload", "file") -} - -func (suite *Suite) TearDownSuite() { - suite.Suite.TearDownSuite() -} - -// workPath returns a unique (relative-to-WorkPath, absolute-on-disk) path pair. -// The relative form is what the client sends to the jailed daemon. -func (suite *Suite) workPath(name string) (rel, abs string) { - suite.T().Helper() - suite.seq++ - rel = "ft/" + name + "_" + strconv.Itoa(suite.seq) - abs = filepath.Join(suite.WorkPath, filepath.FromSlash(rel)) - - return rel, abs -} - -// workDir creates a unique directory inside WorkPath and returns its -// (relative, absolute) path pair. -func (suite *Suite) workDir(name string) (rel, abs string) { - suite.T().Helper() - rel, abs = suite.workPath(name) - if err := os.MkdirAll(abs, 0o755); err != nil { - suite.T().Fatal(err) - } - - return rel, abs -} - -// workFile creates a unique file (with its parent directory) inside WorkPath -// and returns its (relative, absolute) path pair. -func (suite *Suite) workFile(name string, contents []byte) (rel, abs string) { - suite.T().Helper() - rel, abs = suite.workPath(name) - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - suite.T().Fatal(err) - } - if err := os.WriteFile(abs, contents, 0o644); err != nil { - suite.T().Fatal(err) - } - - return rel, abs -} - -func (suite *Suite) fixtureAbs(name string) string { - suite.T().Helper() - - return filepath.Join(suite.WorkPath, fixturesRel, name) -} - -func (suite *Suite) Authenticate() { - suite.T().Helper() - suite.Auth(server.ModeFiles) -} - -func (suite *Suite) readMessageFromClient() []interface{} { - suite.T().Helper() - - buf := make([]byte, 256) - suite.ClientRead(buf) - var msg []interface{} - err := decode.Unmarshal(buf, &msg) - if err != nil { - suite.T().Fatal(err) - } - - return msg -} - -func (suite *Suite) assertFileDetails( - fInfo []interface{}, - name string, - size uint64, - fileType files.FileType, - permissions uint16, - mime string, -) { - suite.T().Helper() - - // file name - suite.Equal(name, fInfo[0]) - - if runtime.GOOS == "windows" && fileType == files.TypeSymlink { - suite.T().Log("ignore symlink assertion in windows") - return - } - - // file size - if fileType != files.TypeDir { - //nolint:gocritic - switch fInfo[1].(type) { - case uint8: - suite.Equal(size, uint64(fInfo[1].(uint8))) - case uint16: - suite.Equal(size, uint64(fInfo[1].(uint16))) - case uint32: - suite.Equal(size, uint64(fInfo[1].(uint32))) - case uint64: - suite.Equal(size, fInfo[1].(uint64)) - } - } - - // file type (file, directory, ...) - suite.Equal(uint8(fileType), fInfo[2]) - - // permissions. A symlink's own lstat permission bits are only - // deterministic on Linux (always 0777); macOS reports the real bits, so - // the strict check is limited to where it is meaningful. - if runtime.GOOS != "windows" && (fileType != files.TypeSymlink || runtime.GOOS == "linux") { - suite.Equal(permissions, fInfo[6]) - } - - // mime type - suite.Equal(mime, fInfo[7]) -} - -func (suite *Suite) assertUploadedFileContents(expected []byte) { - suite.T().Helper() - - contents, err := os.ReadFile(suite.tempFileDestination) - if err != nil { - suite.T().Fatal(err) - } - suite.Equal(expected, contents) -} - -func (suite *Suite) assertFirstAndLastFileBytes(first []byte, last []byte) { - suite.T().Helper() - - contents, err := os.ReadFile(suite.tempFileDestination) - if err != nil { - suite.T().Fatal(err) - } - - firstLen := len(first) - lastLen := len(last) - - suite.Equal(first, contents[:firstLen]) - suite.Equal(last, contents[len(contents)-lastLen:]) -} - -func (suite *Suite) assertUploadedFileSize(expected int) { - suite.T().Helper() - - suite.Require().FileExists(suite.tempFileDestination) - stat, err := os.Stat(suite.tempFileDestination) - if err != nil { - suite.T().Fatal(err) - } - - suite.Equal(int64(expected), stat.Size()) -} - -func (suite *Suite) givenUploadMessage(size int) []interface{} { - suite.T().Helper() - - return []interface{}{ - files.FileSend, - files.GetFileFromClient, - suite.relFileDestination, - uint64(size), - true, - 0666, - } -} diff --git a/test/functional/servertest/files/upload_download_test.go b/test/functional/servertest/files/upload_download_test.go deleted file mode 100644 index b8a4e4f..0000000 --- a/test/functional/servertest/files/upload_download_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package files - -import ( - "os" - - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/files" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/stretchr/testify/assert" -) - -func (suite *Suite) TestDownloadSuccess() { - suite.Auth(server.ModeFiles) - msg := []interface{}{files.FileSend, files.SendFileToClient, fixturesRel + "/file.txt"} - r := suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - suite.Equal("File is ready to transfer", r[1].(string)) - suite.Equal(uint8(9), r[2].(uint8)) - - // Transfer the file - buf := make([]byte, 9) - suite.ClientRead(buf) - - suite.Equal("file.txt\n", string(buf)) -} - -func (suite *Suite) TestDownloadMultipleSuccess() { - // First File - suite.Auth(server.ModeFiles) - msg := []interface{}{files.FileSend, files.SendFileToClient, fixturesRel + "/file.txt"} - r := suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - suite.Equal("File is ready to transfer", r[1].(string)) - suite.Equal(uint8(9), r[2].(uint8)) - - buf := make([]byte, 9) - suite.ClientRead(buf) - - suite.Equal("file.txt\n", string(buf)) - - // Second File - msg = []interface{}{files.FileSend, files.SendFileToClient, fixturesRel + "/file2.txt"} - r = suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - suite.Equal("File is ready to transfer", r[1].(string)) - suite.Equal(uint8(10), r[2].(uint8)) - - buf2 := make([]byte, 10) - suite.ClientRead(buf2) - - suite.Equal("file2.txt\n", string(buf2)) -} - -func (suite *Suite) TestDownload_EmptyFile_Success() { - suite.Auth(server.ModeFiles) - msg := []interface{}{files.FileSend, files.SendFileToClient, fixturesRel + "/empty_file.txt"} - r := suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - suite.Equal("File is ready to transfer", r[1].(string)) - suite.Equal(uint8(0), r[2].(uint8)) - - // Transfer the file - buf := make([]byte, 0) - suite.ClientRead(buf) - - suite.Equal("", string(buf)) -} - -func (suite *Suite) TestUploadSuccess() { - suite.Authenticate() - fileContents := []byte{'f', 'i', 'l', 'e', 'c', 'o', 'n', 't', 'e', 'n', 't', 's'} - msg := suite.givenUploadMessage(len(fileContents)) - r := suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - - // Transfer the file - suite.ClientFileContentsWrite(fileContents) - r = suite.readMessageFromClient() - - assert.Equal(suite.T(), response.StatusOK, response.Code(r[0].(uint8))) - suite.assertUploadedFileSize(len(fileContents)) - suite.assertUploadedFileContents(fileContents) -} - -func (suite *Suite) TestUploadTwiceSuccess() { - suite.Authenticate() - fileContents := []byte{'f', 'i', 'l', 'e', 'c', 'o', 'n', 't', 'e', 'n', 't', 's'} - msg := suite.givenUploadMessage(len(fileContents)) - r := suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - - // Transfer the file - suite.ClientFileContentsWrite(fileContents) - r = suite.readMessageFromClient() - - assert.Equal(suite.T(), response.StatusOK, response.Code(r[0].(uint8))) - suite.assertUploadedFileSize(len(fileContents)) - suite.assertUploadedFileContents(fileContents) - - // Upload file with the same name and smaller size - fileContents = []byte{'s', 'm', 'a', 'l', 'l', 'e', 'r'} - msg = suite.givenUploadMessage(len(fileContents)) - r = suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - - // Transfer the file - suite.ClientFileContentsWrite(fileContents) - r = suite.readMessageFromClient() - - assert.Equal(suite.T(), response.StatusOK, response.Code(r[0].(uint8))) - suite.assertUploadedFileSize(len(fileContents)) - suite.assertUploadedFileContents(fileContents) -} - -func (suite *Suite) TestUploadBigFileSuccess() { - suite.Authenticate() - msg := suite.givenUploadMessage(1000000) - r := suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - - // Transfer the file - for i := 0; i < 100000; i++ { - suite.ClientFileContentsWrite([]byte(`_big_file_`)) - } - r = suite.readMessageFromClient() - - assert.Equal(suite.T(), response.StatusOK, response.Code(r[0].(uint8))) - suite.Require().FileExists(suite.tempFileDestination) - suite.assertUploadedFileSize(1000000) - suite.assertFirstAndLastFileBytes([]byte(`_big_file__big_file_`), []byte(`_big_file__big_file_`)) -} - -func (suite *Suite) TestUploadRaccoonSuccess() { - suite.Authenticate() - fileContents, err := os.ReadFile("../../../files/raccoon.jpg") - if err != nil { - suite.T().Fatal(err) - } - msg := suite.givenUploadMessage(len(fileContents)) - r := suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - - suite.ClientFileContentsWrite(fileContents) - r = suite.readMessageFromClient() - - assert.Equal(suite.T(), response.StatusOK, response.Code(r[0].(uint8))) - suite.assertUploadedFileSize(len(fileContents)) - suite.assertUploadedFileContents(fileContents) -} - -func (suite *Suite) TestUpload_WhenListDirectoryCommandExecutedBefore_Success() { - suite.Authenticate() - // Read Directory - readDirMsg := []interface{}{files.ReadDir, fixturesRel, files.ListWithDetails} - r := suite.ClientWriteReadAndDecodeList(readDirMsg) - suite.Require().Equal(response.StatusOK, response.Code(r[0].(uint8))) - // File arrange - fileContents := []byte(`filecontents`) - msg := suite.givenUploadMessage(len(fileContents)) - r = suite.ClientWriteReadAndDecodeList(msg) - suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) - - suite.ClientFileContentsWrite(fileContents) - r = suite.readMessageFromClient() - - assert.Equal(suite.T(), response.StatusOK, response.Code(r[0].(uint8))) - suite.assertUploadedFileSize(len(fileContents)) - suite.assertUploadedFileContents(fileContents) -} diff --git a/test/functional/servertest/files/validation_test.go b/test/functional/servertest/files/validation_test.go deleted file mode 100644 index 151e0cb..0000000 --- a/test/functional/servertest/files/validation_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package files - -import ( - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/stretchr/testify/assert" -) - -func (suite *Suite) TestEmptyMessage() { - suite.Auth(server.ModeFiles) - msg := []interface{}{} - - r := suite.ClientWriteReadAndDecodeList(msg) - - assert.Equal(suite.T(), response.StatusError, response.Code(r[0].(uint8))) - assert.Equal(suite.T(), "Invalid message", r[1]) -} - -func (suite *Suite) TestStringMessage() { - suite.Auth(server.ModeFiles) - msg := "strings" - - r := suite.ClientWriteReadAndDecodeList(msg) - - assert.Equal(suite.T(), response.StatusError, response.Code(r[0].(uint8))) - assert.Contains(suite.T(), r[1], "Failed to decode message") -} - -func (suite *Suite) TestInvalidOperationCode() { - suite.Auth(server.ModeFiles) - msg := []interface{}{0xFF} - - r := suite.ClientWriteReadAndDecodeList(msg) - - assert.Equal(suite.T(), response.StatusError, response.Code(r[0].(uint8))) - assert.Equal(suite.T(), "Invalid operation", r[1]) -} diff --git a/test/functional/servertest/status/status_test.go b/test/functional/servertest/status/status_test.go deleted file mode 100644 index 13b6ba3..0000000 --- a/test/functional/servertest/status/status_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package status - -import ( - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/gameap/daemon/internal/app/server/status" -) - -func (suite *Suite) TestVersionSuccess() { - suite.Auth(server.ModeStatus) - - r := suite.ClientWriteReadAndDecodeList([]interface{}{status.Version}) - - suite.Require().Equal(response.StatusOK, response.Code(r[0].(uint8))) -} - -func (suite *Suite) TestStatusBaseSuccess() { - suite.Auth(server.ModeStatus) - - r := suite.ClientWriteReadAndDecodeList([]interface{}{status.StatusBase}) - - suite.Require().Equal(response.StatusOK, response.Code(r[0].(uint8))) -} - -func (suite *Suite) TestVersionAndStatusSuccess() { - suite.Auth(server.ModeStatus) - - r1 := suite.ClientWriteReadAndDecodeList([]interface{}{status.Version}) - r2 := suite.ClientWriteReadAndDecodeList([]interface{}{status.StatusBase}) - - suite.Require().Equal(response.StatusOK, response.Code(r1[0].(uint8))) - suite.Require().Equal(response.StatusOK, response.Code(r2[0].(uint8))) -} diff --git a/test/functional/servertest/status/suite_test.go b/test/functional/servertest/status/suite_test.go deleted file mode 100644 index 045f8f7..0000000 --- a/test/functional/servertest/status/suite_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package status - -import ( - "testing" - - "github.com/gameap/daemon/test/functional/servertest" - "github.com/stretchr/testify/suite" -) - -type Suite struct { - servertest.Suite -} - -func TestSuite(t *testing.T) { - suite.Run(t, new(Suite)) -} diff --git a/test/functional/servertest/suite.go b/test/functional/servertest/suite.go deleted file mode 100644 index 9d8bc4c..0000000 --- a/test/functional/servertest/suite.go +++ /dev/null @@ -1,235 +0,0 @@ -package servertest - -import ( - "bytes" - "context" - "crypto/tls" - "os" - "time" - - "github.com/et-nik/binngo" - "github.com/et-nik/binngo/decode" - "github.com/gameap/daemon/internal/app/components" - "github.com/gameap/daemon/internal/app/contracts" - "github.com/gameap/daemon/internal/app/server" - "github.com/gameap/daemon/internal/app/server/response" - "github.com/gameap/daemon/test/mocks" - "github.com/pkg/errors" - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -const ( - ServerCert = "../../../../config/certs/server.crt" - ServerKey = "../../../../config/certs/server.key" - ClientCert = "../../../../config/certs/client.crt" - ClientKey = "../../../../config/certs/client.key" -) - -const timeout = 20 * time.Second - -type Suite struct { - suite.Suite - Server *server.Server - Client *tls.Conn - - WorkPath string - - Executor contracts.Executor - TaskStatsReader *mocks.TasksStatsReader -} - -func (suite *Suite) SetupSuite() { - var err error - - suite.TaskStatsReader = &mocks.TasksStatsReader{} - suite.Executor = components.NewCleanExecutor() - - suite.WorkPath, err = os.MkdirTemp("", "gameap-servertest-") - if err != nil { - suite.T().Fatal(err) - } - - certPEM, err := os.ReadFile(ServerCert) - if err != nil { - suite.T().Fatal(err) - } - keyPEM, err := os.ReadFile(ServerKey) - if err != nil { - suite.T().Fatal(err) - } - - suite.Server, err = server.NewServer( - "127.0.0.1", - 3717, - suite.WorkPath, - certPEM, - keyPEM, - server.CredentialsConfig{ - PasswordAuthentication: true, - Login: "login", - Password: "password", - }, - suite.Executor, - suite.TaskStatsReader, - ) - if err != nil { - suite.T().Fatal(err) - } - - log.SetLevel(log.ErrorLevel) - - go func() { - err := suite.Server.Run(context.Background()) - if err != nil { - panic(err) - } - }() - - time.Sleep(10 * time.Millisecond) -} - -func (suite *Suite) SetupTest() { - suite.loadClient() -} - -func (suite *Suite) TearDownTest() { - suite.closeClient() -} - -func (suite *Suite) TearDownSuite() { - suite.Server.Stop(context.Background()) - - if suite.WorkPath != "" { - _ = os.RemoveAll(suite.WorkPath) - } -} - -func (suite *Suite) loadClient() { - suite.T().Helper() - - cer, err := tls.LoadX509KeyPair(ClientCert, ClientKey) - if err != nil { - suite.T().Fatal(err) - } - - conf := &tls.Config{ - Certificates: []tls.Certificate{cer}, - InsecureSkipVerify: true, - } - - conn, err := tls.Dial("tcp", "127.0.0.1:3717", conf) - if err != nil { - suite.T().Fatal(err) - } - - suite.Client = conn -} - -func (suite *Suite) closeClient() { - suite.T().Helper() - - if suite.Client == nil { - return - } - - err := suite.Client.Close() - if err != nil { - suite.T().Fatal(err) - } -} - -func (suite *Suite) Auth(mode server.Mode) { - suite.T().Helper() - - msg, err := binngo.Marshal([]interface{}{0, "login", "password", mode}) - if err != nil { - suite.T().Fatal(err) - } - suite.ClientWrite(msg) - - buf := make([]byte, 256) - suite.ClientRead(buf) - var status response.Response - - err = decode.Unmarshal(buf, &status) - - if !suite.Assert().NoError(err) { - suite.T().Fatal(err) - } - - if !assert.Equal(suite.T(), response.StatusOK, status.Code) { - suite.T().Fatal("Must be status ok") - } -} - -func (suite *Suite) ClientWrite(b []byte) { - suite.T().Helper() - - err := suite.Client.SetWriteDeadline(time.Now().Add(timeout)) - if err != nil { - suite.T().Fatal(err) - } - _, err = suite.Client.Write(b) - if err != nil { - suite.T().Fatal(err) - } - - _, err = suite.Client.Write([]byte{0xFF, 0xFF, 0xFF, 0xFF}) - if err != nil { - suite.T().Fatal(err) - } -} - -func (suite *Suite) ClientFileContentsWrite(b []byte) { - suite.T().Helper() - - err := suite.Client.SetWriteDeadline(time.Now().Add(timeout)) - if err != nil { - suite.T().Fatal(err) - } - _, err = suite.Client.Write(b) - if err != nil { - suite.T().Fatal(err) - } -} - -func (suite *Suite) ClientRead(b []byte) { - suite.Client.ConnectionState() - err := suite.Client.SetReadDeadline(time.Now().Add(timeout)) - if err != nil { - suite.T().Fatal(err) - } - - _, err = suite.Client.Read(b) - if err != nil { - suite.T().Fatal(err) - } -} - -func (suite *Suite) ClientWriteReadAndDecodeList(msg interface{}) []interface{} { - suite.T().Helper() - - b, err := binngo.Marshal(msg) - if err != nil { - suite.T().Fatal(err) - } - - suite.ClientWrite(b) - - var r []interface{} - decoder := decode.NewDecoder(suite.Client) - err = decoder.Decode(&r) - if err != nil { - suite.T().Fatal(errors.WithMessage(err, "failed to unmarshal client response")) - } - - endBytes := make([]byte, 4) - suite.ClientRead(endBytes) - if !bytes.Equal(endBytes, []byte{0xFF, 0xFF, 0xFF, 0xFF}) { - suite.T().Fatal("invalid end bytes") - } - - return r -} diff --git a/test/manual/client/client.go b/test/manual/client/client.go deleted file mode 100644 index b4d8743..0000000 --- a/test/manual/client/client.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "crypto/tls" - "log" - - "github.com/et-nik/binngo" - "github.com/et-nik/binngo/decode" -) - -func main() { - log.SetFlags(log.Lshortfile) - - cer, err := tls.LoadX509KeyPair("./config/certs/client.crt", "./config/certs/client.key") - if err != nil { - panic(err) - } - - conf := &tls.Config{ - Certificates: []tls.Certificate{cer}, - InsecureSkipVerify: true, - } - - conn, err := tls.Dial("tcp", "127.0.0.1:31717", conf) - if err != nil { - log.Println(err) - return - } - defer conn.Close() - - msg := []interface{}{0, "login", "password"} - - message, err := binngo.Marshal(msg) - if err != nil { - return - } - - n, err := conn.Write(message) - if err != nil { - log.Println(n, err) - return - } - - var status []interface{} - decoder := decode.NewDecoder(conn) - err = decoder.Decode(&status) - if err != nil { - log.Println(err) - return - } - - log.Println(msg) -} diff --git a/test/mocks/gdtask_repository.go b/test/mocks/gdtask_repository.go deleted file mode 100644 index 5cf7185..0000000 --- a/test/mocks/gdtask_repository.go +++ /dev/null @@ -1,77 +0,0 @@ -package mocks - -import ( - "context" - "sync" - - "github.com/gameap/daemon/internal/app/domain" -) - -type GDTaskRepository struct { - items map[int]*domain.GDTask - mutex *sync.Mutex -} - -func NewGDTaskRepository() *GDTaskRepository { - return &GDTaskRepository{ - items: make(map[int]*domain.GDTask), - mutex: &sync.Mutex{}, - } -} - -func (r *GDTaskRepository) FindByStatus(_ context.Context, status domain.GDTaskStatus) ([]*domain.GDTask, error) { - r.mutex.Lock() - defer r.mutex.Unlock() - - var result []*domain.GDTask - - for _, v := range r.items { - if v.Status() == status { - result = append(result, v) - } - } - - return result, nil -} - -func (r *GDTaskRepository) FindByID(_ context.Context, id int) (*domain.GDTask, error) { - r.mutex.Lock() - defer r.mutex.Unlock() - - for _, v := range r.items { - if v.ID() == id { - return v, nil - } - } - - return nil, nil -} - -func (r *GDTaskRepository) Save(_ context.Context, task *domain.GDTask) error { - r.mutex.Lock() - defer r.mutex.Unlock() - - r.items[task.ID()] = task - - return nil -} - -func (r *GDTaskRepository) AppendOutput(_ context.Context, _ *domain.GDTask, _ []byte) error { - return nil -} - -func (r *GDTaskRepository) Set(items []*domain.GDTask) { - r.mutex.Lock() - defer r.mutex.Unlock() - - for _, v := range items { - r.items[v.ID()] = v - } -} - -func (r *GDTaskRepository) Clear() { - r.mutex.Lock() - defer r.mutex.Unlock() - - r.items = map[int]*domain.GDTask{} -} diff --git a/test/mocks/server_task_repository.go b/test/mocks/server_task_repository.go deleted file mode 100644 index fe4ec26..0000000 --- a/test/mocks/server_task_repository.go +++ /dev/null @@ -1,89 +0,0 @@ -package mocks - -import ( - "context" - "sync" - - "github.com/gameap/daemon/internal/app/domain" -) - -type ServerTaskRepository struct { - items map[int]*domain.ServerTask - fails map[int][][]byte - mutex sync.Mutex -} - -func NewServerTaskRepository() *ServerTaskRepository { - return &ServerTaskRepository{ - items: make(map[int]*domain.ServerTask), - fails: make(map[int][][]byte), - } -} - -func (r *ServerTaskRepository) Find(_ context.Context) ([]*domain.ServerTask, error) { - r.mutex.Lock() - defer r.mutex.Unlock() - - items := make([]*domain.ServerTask, 0, len(r.items)) - - for _, v := range r.items { - items = append(items, v) - } - - return items, nil -} - -func (r *ServerTaskRepository) FindByID(_ context.Context, id int) (*domain.ServerTask, error) { - r.mutex.Lock() - defer r.mutex.Unlock() - - item, exists := r.items[id] - if !exists { - return nil, nil - } - - return item, nil -} - -func (r *ServerTaskRepository) Save(_ context.Context, task *domain.ServerTask) error { - r.mutex.Lock() - defer r.mutex.Unlock() - - r.items[task.ID()] = task - - return nil -} - -func (r *ServerTaskRepository) Fail(_ context.Context, task *domain.ServerTask, output []byte) error { - r.mutex.Lock() - defer r.mutex.Unlock() - - fails, ok := r.fails[task.ID()] - if !ok { - r.fails[task.ID()] = [][]byte{} - fails = r.fails[task.ID()] - } - - fails = append(fails, output) - - r.fails[task.ID()] = fails - - return nil -} - -func (r *ServerTaskRepository) Set(items []*domain.ServerTask) { - r.mutex.Lock() - defer r.mutex.Unlock() - - for _, v := range items { - r.items[v.ID()] = v - } -} - -func (r *ServerTaskRepository) Clear() { - r.mutex.Lock() - defer r.mutex.Unlock() - - r.items = map[int]*domain.ServerTask{} - r.fails = make(map[int][][]byte) -} diff --git a/test/mocks/task_stats_reader.go b/test/mocks/task_stats_reader.go deleted file mode 100644 index 002d036..0000000 --- a/test/mocks/task_stats_reader.go +++ /dev/null @@ -1,17 +0,0 @@ -package mocks - -import ( - "github.com/gameap/daemon/internal/app/domain" -) - -type TasksStatsReader struct { - WorkingCount int - WaitingCount int -} - -func (t *TasksStatsReader) Stats() domain.GDTaskStats { - return domain.GDTaskStats{ - WorkingCount: t.WorkingCount, - WaitingCount: t.WaitingCount, - } -}