From 0a26afea6ba64412fe7b660d06451acba88c98a2 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 29 Sep 2026 11:43:24 +0000 Subject: [PATCH 01/10] Owner reports: ipctool output, backups and photos, kept apart from every import A new catalogue entity for what camera owners and AI agents send about a board: ipctool's YAML, optionally a full-flash backup, photos, a boot log and the U-Boot environment. POST /api/v1/reports stores it at once and answers a receipt; nothing is public until `openipc reports publish`. The public copy has the MAC, die ID and cloud ID replaced by keyed hashes, and a backup is served only when its owner sent consent=public. POST /api/v1/boards/identify matches ipctool's output to catalogue boards and stores nothing. A report exists once, on this host, so nothing the board importers do can reach it: - its own tables (migration 016), which no package but internal/reports may name (deploytest); - triggers that refuse UPDATE, DELETE and TRUNCATE unless takedown or unlink stood them down for their own transaction; - links to board models ON DELETE RESTRICT, so a model delete fails instead of cascading; - files content-addressed and written once under /srv/www/shared/owner-reports, not the analytics' shared/reports, which dev serves with autoindex; - survival_test.go, which runs the archive import, every donor snapshot, every vendor-firmware push, the purge and the migrations twice over stored reports and requires them byte-identical. A planted DELETE in the snapshot importer fails it, and so does one that stands the guard down. Each file goes to S3 the night after it arrives, under the boards/ prefix the backup's IAM policy already allows. `openipc reports verify` re-hashes every file nightly. nginx answers the upload on port 80 too, because ipctool on stock firmware has no TLS. --- CLAUDE.md | 11 + deploy/RESTORE.md | 23 +- deploy/backup-db.sh | 40 ++ deploy/deploy.sh | 14 +- deploy/docker-compose.yml | 2 + deploy/install-go-service.sh | 2 +- deploy/nginx/check-config.sh | 40 +- .../conf.d/openipc-datacentre-block.conf | 5 + deploy/nginx/sites-available/org.openipc | 90 ++++ deploy/nginx/sites-available/org.openipc.dev | 90 ++++ deploy/purge-snapshots.sh | 10 + deploy/refresh-dev.sh | 13 + deploy/static/reserved-paths | 2 + service/README.md | 16 + service/cmd/openipc/main.go | 16 +- service/cmd/openipc/reports.go | 177 +++++++ service/deploytest/boards_test.go | 2 +- service/deploytest/reports_test.go | 141 +++++ service/internal/boards/export_test.go | 28 + service/internal/boards/survival_test.go | 228 ++++++++ service/internal/config/config.go | 8 + .../internal/db/migrations/016_reports.sql | 129 +++++ service/internal/reports/api_test.go | 357 +++++++++++++ service/internal/reports/backup.go | 80 +++ service/internal/reports/files.go | 158 ++++++ service/internal/reports/handler.go | 494 ++++++++++++++++++ service/internal/reports/identify.go | 161 ++++++ service/internal/reports/parse.go | 242 +++++++++ service/internal/reports/parse_test.go | 156 ++++++ service/internal/reports/store.go | 296 +++++++++++ .../reports/testdata/hi3516cv300-imx291.txt | 90 ++++ .../reports/testdata/hi3516ev300-imx335.txt | 70 +++ .../internal/reports/testdata/t31-sc2332.txt | 48 ++ .../testdata/xiongmai-50h20l-readme.yml | 106 ++++ service/internal/reports/view.go | 220 ++++++++ service/routes.json | 20 + 36 files changed, 3574 insertions(+), 11 deletions(-) create mode 100644 service/cmd/openipc/reports.go create mode 100644 service/deploytest/reports_test.go create mode 100644 service/internal/boards/export_test.go create mode 100644 service/internal/boards/survival_test.go create mode 100644 service/internal/db/migrations/016_reports.sql create mode 100644 service/internal/reports/api_test.go create mode 100644 service/internal/reports/backup.go create mode 100644 service/internal/reports/files.go create mode 100644 service/internal/reports/handler.go create mode 100644 service/internal/reports/identify.go create mode 100644 service/internal/reports/parse.go create mode 100644 service/internal/reports/parse_test.go create mode 100644 service/internal/reports/store.go create mode 100644 service/internal/reports/testdata/hi3516cv300-imx291.txt create mode 100644 service/internal/reports/testdata/hi3516ev300-imx335.txt create mode 100644 service/internal/reports/testdata/t31-sc2332.txt create mode 100644 service/internal/reports/testdata/xiongmai-50h20l-readme.yml create mode 100644 service/internal/reports/view.go diff --git a/CLAUDE.md b/CLAUDE.md index fad4cab1..b9d4b33d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,17 @@ restores the image but never the schema, so keep migrations additive. environment once. Boards link to device IDs through `board_device_ids` (tehno32's firmware pages, cctvsp) or a firmware named after the board; a board with a coupler image is OpenIPC-ready, derived on read, never stored. +- `internal/reports` — **owner reports**: ipctool's output, flash backups, + photos and console captures that camera owners and AI agents send to + `POST /api/v1/reports` (and `POST /api/v1/boards/identify`, which stores + nothing). Public only after `openipc reports publish`; identifiers replaced + by keyed hashes in the public copy; a backup served only with + `consent=public`. **Nothing but this package may write them**: triggers + refuse changes, board links are `ON DELETE RESTRICT`, files are + content-addressed and written once under `REPORTS_ROOT` + (`/srv/www/shared/owner-reports` -- not `shared/reports`, which is the + analytics'), and `internal/boards/survival_test.go` runs every importer + over stored reports. - `internal/catalogue` — **the hardware catalogue is `data/catalogue/*.yml` and nothing else** (#289). The service reads it at start; the site reads its export. Change it by editing the YAML in a pull request, then run the export. diff --git a/deploy/RESTORE.md b/deploy/RESTORE.md index f1ceb413..d964d84c 100644 --- a/deploy/RESTORE.md +++ b/deploy/RESTORE.md @@ -37,6 +37,12 @@ the team password manager. The server can write backups it cannot read. captures), as `boards/boards--.tar`, outside the daily lifecycle. The newest one is the current set; see step 3c. +**Backed up the night after they arrive:** owner reports' files +(`/srv/www/shared/owner-reports`: what camera owners and agents sent, private +flash backups among them), one object per file as +`boards/owner-reports/sha256//`, never overwritten or deleted; +see step 3d. + **Not backed up, by decision:** the wall images (snapshots purge at 2 days and cameras re-upload continuously), the firmware cache (`/srv/www/shared/firmware`, one version of each image, rebuilt on the next request), `/srv/github-releases` @@ -122,7 +128,7 @@ run the installer. ### 3c. Restore the board catalogue's files ```bash -aws s3 ls s3://openipc-org-backup/boards/ | sort | tail -1 # the newest set +aws s3 ls s3://openipc-org-backup/boards/ | grep ' boards-' | sort | tail -1 # the newest set aws s3 cp s3://openipc-org-backup/boards/boards--.tar . install -d -o 1000 -g 1000 -m 0755 /srv/www/shared/boards tar -C /srv/www/shared/boards -xf boards--.tar && chown -R 1000:1000 /srv/www/shared/boards @@ -133,6 +139,21 @@ point at files that are not there; `openipc boards import-openhisiipcam` rewrites the OpenHisiIpCam ones from GitHub while the archive exists, but skips every unit it already holds, so delete those units' rows first. +### 3d. Restore owner reports' files + +```bash +install -d -o 1000 -g 1000 -m 0755 /srv/www/shared/owner-reports +aws s3 cp --recursive s3://openipc-org-backup/boards/owner-reports/sha256/ /srv/www/shared/owner-reports/sha256/ +chown -R 1000:1000 /srv/www/shared/owner-reports +find /srv/www/shared/owner-reports/sha256 -type f -exec chmod 0444 {} + +``` + +Each file is named by its sha256, so the copy checks itself. Once the +database is back (step 4), `docker exec openipc-go-web-prod openipc reports +verify` re-reads every file the rows name and fails on any missing or +changed. These files exist nowhere else: a report whose file is lost cannot +be sent again by anyone. + ### 4. The database `deploy/install-go-service.sh` installs PostgreSQL 17, creates the two diff --git a/deploy/backup-db.sh b/deploy/backup-db.sh index d22c96c4..85506b94 100755 --- a/deploy/backup-db.sh +++ b/deploy/backup-db.sh @@ -16,6 +16,9 @@ # The board catalogue's files (/srv/www/shared/boards) go up too, but # only when they change, under boards/ rather than daily/: the archive # they came from may not outlive us, and the rows name them. +# Owner reports' files (/srv/www/shared/owner-reports) go up one object +# per file under boards/owner-reports/, each once, the night after it +# arrives: they exist nowhere else. # Out: the wall's images (snapshots purge at 2 days and cameras re-upload # continuously), /srv/github-releases (refreshed hourly from GitHub) and # the firmware cache (rebuilt on demand). @@ -232,4 +235,41 @@ else log "no board files on this host, skipping" fi +# ------------------------------------------------------------ owner reports +# What camera owners and agents sent (service/internal/reports): ipctool's +# output, photos, console captures, and flash backups -- some of them private, +# which is why the bucket is private and TLS-only. The rows are in the dump +# above; the files exist on this host and nowhere else, so every one goes up +# the night after it arrives. Content-addressed and written once, so a file +# is uploaded once, under its own name, and never overwritten or deleted: +# boards/owner-reports/sha256//, inside the prefix the backup's IAM +# policy already lets this host write. The mark lists what is up already. +REPORTS_ROOT=/srv/www/shared/owner-reports +REPORTS_MARK=/srv/www/.owner-reports-backed-up +if [ -d "$REPORTS_ROOT/sha256" ]; then + touch "$REPORTS_MARK" + up=0 + while IFS= read -r -d '' f; do + sum=$(basename "$f") + grep -qx "$sum" "$REPORTS_MARK" && continue + [ "$(sha256sum "$f" | cut -d' ' -f1)" = "$sum" ] || fail "owner report file ${sum} does not hold the bytes its name says" + key="boards/owner-reports/sha256/${sum:0:2}/${sum}" + size=$(stat -c %s "$f") + if [ "$DRY_RUN" = 1 ]; then + log "DRY RUN would upload ${key} (${size} bytes)" + continue + fi + "${AWS[@]}" s3 cp --only-show-errors "$f" "s3://${S3_BUCKET}/${key}" \ + || fail "upload of owner report file ${sum} failed" + REMOTE=$("${AWS[@]}" s3api head-object --bucket "$S3_BUCKET" --key "$key" \ + --query ContentLength --output text 2>/dev/null) || fail "owner report file ${sum} is not readable back" + [ "$REMOTE" = "$size" ] || fail "owner report file ${sum}: local ${size} bytes, remote ${REMOTE}" + echo "$sum" >> "$REPORTS_MARK" + up=$((up + 1)) + done < <(find "$REPORTS_ROOT/sha256" -type f -print0) + log "owner report files: ${up} uploaded, $(wc -l < "$REPORTS_MARK") backed up in all" +else + log "no owner report files on this host, skipping" +fi + log "backup complete" diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 89ee3f1f..2c82853a 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -99,11 +99,11 @@ env_set() { warn() { printf '\033[33m==>\033[0m %s\n' "$*" >&2; } -# web firmware ports tag rollback file firmware cache release cache wall boards +# web firmware ports tag rollback file firmware cache release cache wall boards owner reports target_for() { case "$1" in - prod) echo "go-web-prod go-firmware-prod 3002 3003 GO_PROD_TAG .previous-prod /srv/www/shared/firmware /srv/www/shared/go-release-cache /srv/www/shared/wall /srv/www/shared/boards" ;; - dev) echo "go-web-dev go-firmware-dev 3012 3013 GO_DEV_TAG .previous-dev /srv/www/shared/dev-firmware /srv/www/shared/dev-go-release-cache /srv/www/shared/dev-wall /srv/www/shared/dev-boards" ;; + prod) echo "go-web-prod go-firmware-prod 3002 3003 GO_PROD_TAG .previous-prod /srv/www/shared/firmware /srv/www/shared/go-release-cache /srv/www/shared/wall /srv/www/shared/boards /srv/www/shared/owner-reports" ;; + dev) echo "go-web-dev go-firmware-dev 3012 3013 GO_DEV_TAG .previous-dev /srv/www/shared/dev-firmware /srv/www/shared/dev-go-release-cache /srv/www/shared/dev-wall /srv/www/shared/dev-boards /srv/www/shared/dev-owner-reports" ;; *) die "unknown target '$1' (expected prod or dev)" ;; esac } @@ -161,8 +161,8 @@ do_deploy() { # off $1 rather than env_name below so it can stay first. checkout_warn "$CHECKOUT_DIR" "$(checkout_branch_for "${1:-prod}")" local env_name=$1 sha=${2:-latest} - local web fw web_port fw_port tag_key prev_file fw_cache rel_cache wall_root boards_root - read -r web fw web_port fw_port tag_key prev_file fw_cache rel_cache wall_root boards_root <<<"$(target_for "$env_name")" + local web fw web_port fw_port tag_key prev_file fw_cache rel_cache wall_root boards_root reports_root + read -r web fw web_port fw_port tag_key prev_file fw_cache rel_cache wall_root boards_root reports_root <<<"$(target_for "$env_name")" local prev_path="${STATE_DIR}/${prev_file}" [ -f "/srv/www/.env.go-${env_name}" ] \ @@ -176,6 +176,10 @@ do_deploy() { # The board catalogue's files, written by `openipc boards # import-openhisiipcam` inside the web container and served by nginx. ensure_uid_1000_root "$boards_root" + # Owner reports' files (service/internal/reports), written once by the web + # role. Not /srv/www/shared/reports: that is the analytics' directory, and + # dev serves it to anyone with the staging password. + ensure_uid_1000_root "$reports_root" install_legacy_images local previous diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3d21eef0..15c82ea0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -52,6 +52,7 @@ services: - /var/run/postgresql:/var/run/postgresql - /srv/www/shared/wall:/srv/wall - /srv/www/shared/boards:/srv/boards + - /srv/www/shared/owner-reports:/srv/owner-reports go-firmware-prod: <<: *go-common @@ -88,6 +89,7 @@ services: - /var/run/postgresql:/var/run/postgresql - /srv/www/shared/dev-wall:/srv/wall - /srv/www/shared/dev-boards:/srv/boards + - /srv/www/shared/dev-owner-reports:/srv/owner-reports go-firmware-dev: <<: *go-common diff --git a/deploy/install-go-service.sh b/deploy/install-go-service.sh index db053561..db7cb353 100755 --- a/deploy/install-go-service.sh +++ b/deploy/install-go-service.sh @@ -177,7 +177,7 @@ ensure_keys() { } make_directories() { - for d in firmware dev-firmware go-release-cache dev-go-release-cache wall dev-wall boards dev-boards; do + for d in firmware dev-firmware go-release-cache dev-go-release-cache wall dev-wall boards dev-boards owner-reports dev-owner-reports; do install -d -o 1000 -g 1000 -m 0755 "${SHARED}/${d}" done ok "directories under ${SHARED}" diff --git a/deploy/nginx/check-config.sh b/deploy/nginx/check-config.sh index ef94f7b3..bbfe5038 100755 --- a/deploy/nginx/check-config.sh +++ b/deploy/nginx/check-config.sh @@ -104,7 +104,12 @@ exec_sh() { docker exec -i "$cid" sh -s; } # seam and not the application. Both answer 200 to everything, which is what # makes the expected statuses below deterministic. cat > /etc/nginx/conf.d/zz-stub-upstream.conf <<'STUB' -server { listen 127.0.0.1:3002; location / { return 200 "GO-WEB-PROD\n"; } } +server { listen 127.0.0.1:3002; + location ^~ /api/v1/reports/r-test/files/ { + add_header X-Accel-Redirect /report-files/sha256/ab/abcd; + return 200 ""; + } + location / { return 200 "GO-WEB-PROD\n"; } } server { listen 127.0.0.1:3003; location = /api/v1/hardware/availability.json { return 200 "GO-AVAILABILITY\n"; } location ^~ /api/v1/wizard/ { return 200 "GO-WIZARD\n"; } @@ -117,6 +122,8 @@ server { listen 127.0.0.1:3013; location / { return 200 "GO-FIRMWARE-DEV\n"; } } STUB install -d -m 0755 /srv/www/shared/firmware printf 'IMAGE\n' > /srv/www/shared/firmware/image.bin +install -d -m 0755 /srv/www/shared/owner-reports/sha256/ab +printf 'REPORT-FILE\n' > /srv/www/shared/owner-reports/sha256/ab/abcd # One page, which is exactly what the real bundle holds today. install -d -m 0755 /srv/www/static/prod/site-test/_smoke @@ -565,13 +572,42 @@ grep -q GO-WIZARD /tmp/b || { echo " the wizard did not reach the Go firmware p # CI pushes each build to the web role, once (builds/PUSH.md). posts /api/v1/builds 200 go grep -q GO-WEB-PROD /tmp/pb || { echo " the build push did not reach the Go web process"; fail=1; } +# Owner reports (service/internal/reports): the upload and identify reach the +# web role on 443 and on plain 80 (ipctool on stock firmware has no TLS); a +# report's file reaches /report-files/ only through the web role's +# X-Accel-Redirect, never by its address. +posts /api/v1/reports 200 go +grep -q GO-WEB-PROD /tmp/pb || { echo " the report upload did not reach the Go web process"; fail=1; } +posts /api/v1/boards/identify 200 go +for probe in "200 POST /api/v1/reports" "200 POST /api/v1/boards/identify" "301 GET /api/v1/reports/r-x" "301 POST /snapshots"; do + set -- $probe + got=$(curl -sS -o /tmp/p80 -w '%{http_code}' --max-time 5 -X "$2" -F yaml=chip: \ + --resolve "openipc.org:80:127.0.0.1" "http://openipc.org$3" 2>/dev/null) + if [ "$got" = "$1" ]; then + printf ' %-32s %-5s (%s over plain HTTP)\n' "$3" "$got" "$2" + else + printf ' %-32s %-5s (%s over plain HTTP) MISMATCH: want %s\n' "$3" "$got" "$2" "$1" + fail=1 + fi +done +expect /api/v1/reports/r-x 200 go hsts +expect /api/v1/reports/r-test/files/1 200 go hsts +grep -q REPORT-FILE /tmp/b || { echo " a report's file did not reach /report-files/"; fail=1; } +got=$(curl -sS -o /tmp/rf -w '%{http_code}' -k --max-time 5 --resolve "openipc.org:443:127.0.0.1" \ + "https://openipc.org/report-files/sha256/ab/abcd" 2>/dev/null) +if [ "$got" = 404 ] && ! grep -q REPORT-FILE /tmp/rf; then + printf ' %-32s %-5s (internal only)\n' /report-files/sha256/ab/abcd "$got" +else + printf ' %-32s %-5s MISMATCH: a report file is reachable by its address\n' /report-files/sha256/ab/abcd "$got" + fail=1 +fi # From a blocked range: refused everywhere, except the push GitHub's runners # make from Azure (conf.d/openipc-datacentre-block.conf). from_dc() { curl -sS -o /dev/null -w '%{http_code}' -k --max-time 5 --interface 127.0.0.2 \ --resolve "openipc.org:443:127.0.0.1" "$@" 2>/dev/null } -for probe in "403 GET /" "403 GET /.git/config" "403 POST /snapshots" "200 POST /api/v1/builds"; do +for probe in "403 GET /" "403 GET /.git/config" "403 POST /snapshots" "200 POST /api/v1/builds" "200 POST /api/v1/reports"; do set -- $probe got=$(from_dc -X "$2" "https://openipc.org$3") if [ "$got" = "$1" ]; then diff --git a/deploy/nginx/conf.d/openipc-datacentre-block.conf b/deploy/nginx/conf.d/openipc-datacentre-block.conf index 4c00ce19..6038d8f8 100644 --- a/deploy/nginx/conf.d/openipc-datacentre-block.conf +++ b/deploy/nginx/conf.d/openipc-datacentre-block.conf @@ -66,10 +66,15 @@ geo $openipc_datacentre_client { # That endpoint verifies a GitHub Actions OIDC token itself; the address says # nothing it does not already know. # +# Owner reports are sent by AI agents as well as people, and an agent may run +# in a cloud; the upload is limited per address by the web role itself. +# # An exact key wins over a regex in a map, so the push is looked up first. map "$openipc_datacentre_client:$uri" $openipc_datacentre_refused { default 0; "1:/api/v1/builds" 0; + "1:/api/v1/reports" 0; + "1:/api/v1/boards/identify" 0; ~^1: 1; } diff --git a/deploy/nginx/sites-available/org.openipc b/deploy/nginx/sites-available/org.openipc index f8f51e06..965b23d0 100644 --- a/deploy/nginx/sites-available/org.openipc +++ b/deploy/nginx/sites-available/org.openipc @@ -24,6 +24,35 @@ server { return 301 https://$host$request_uri; } + # ipctool uploads a report over plain HTTP: stock camera firmware has no + # TLS. Only the upload and identify are answered here; everything else + # redirects. + location = /api/v1/reports { + client_max_body_size 300m; + client_body_timeout 300; + proxy_request_buffering off; + proxy_read_timeout 300; + proxy_send_timeout 300; + proxy_pass http://127.0.0.1:3002; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + + location = /api/v1/boards/identify { + client_max_body_size 256k; + limit_req zone=boards_search burst=20 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:3002; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + location / { return 301 https://$host$request_uri; } @@ -309,6 +338,67 @@ server { add_header X-Served-By go always; } + # Owner reports (service/internal/reports): ipctool's output, a board's + # flash backup, photos and console captures, sent by a camera's owner or + # an AI agent on a bench. The web role streams each part to disk as it + # arrives, so nothing is buffered here; 300m covers a 256 MB SPI NAND + # backup and its multipart framing. Also answered on port 80 above: + # ipctool on stock firmware has no TLS. + location = /api/v1/reports { + client_max_body_size 300m; + client_body_timeout 300; + proxy_request_buffering off; + proxy_read_timeout 300; + proxy_send_timeout 300; + proxy_pass http://127.0.0.1:3002; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + + # A report's receipt and, once published, its files. The web role decides + # what may be served and hands it to /report-files/ below. + location ^~ /api/v1/reports/ { + limit_req zone=boards_search burst=20 nodelay; + limit_req_status 429; + gzip on; + gzip_proxied any; + gzip_types application/json; + proxy_pass http://127.0.0.1:3002; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + + # Which catalogue boards ipctool's output may be from. Stores nothing. + location = /api/v1/boards/identify { + client_max_body_size 256k; + limit_req zone=boards_search burst=20 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:3002; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + + # The reports' files, content-addressed. Internal: reachable only through + # the web role's X-Accel-Redirect, which sends a published report's + # shareable files and never a private backup. Never alias this directory + # anywhere else. + location ^~ /report-files/ { + internal; + alias /srv/www/shared/owner-reports/; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + add_header X-Content-Type-Options nosniff always; + } + # Where OpenIPC/xmupdates and OpenIPC/coupler push the firmware they # publish for Xiongmai devices, their whole list each time (service/ # internal/vendorfw/PUSH.md). The web role verifies the OIDC token itself. diff --git a/deploy/nginx/sites-available/org.openipc.dev b/deploy/nginx/sites-available/org.openipc.dev index cfcd336a..a5d4eac5 100644 --- a/deploy/nginx/sites-available/org.openipc.dev +++ b/deploy/nginx/sites-available/org.openipc.dev @@ -24,6 +24,35 @@ server { return 301 https://$host$request_uri; } + # ipctool uploads a report over plain HTTP: stock camera firmware has no + # TLS. Only the upload and identify are answered here; everything else + # redirects. + location = /api/v1/reports { + client_max_body_size 300m; + client_body_timeout 300; + proxy_request_buffering off; + proxy_read_timeout 300; + proxy_send_timeout 300; + proxy_pass http://127.0.0.1:3012; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + + location = /api/v1/boards/identify { + client_max_body_size 256k; + limit_req zone=boards_search burst=20 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:3012; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + location / { return 301 https://$host$request_uri; } @@ -250,6 +279,67 @@ server { add_header X-Served-By go always; } + # Owner reports (service/internal/reports): ipctool's output, a board's + # flash backup, photos and console captures, sent by a camera's owner or + # an AI agent on a bench. The web role streams each part to disk as it + # arrives, so nothing is buffered here; 300m covers a 256 MB SPI NAND + # backup and its multipart framing. Also answered on port 80 above: + # ipctool on stock firmware has no TLS. + location = /api/v1/reports { + client_max_body_size 300m; + client_body_timeout 300; + proxy_request_buffering off; + proxy_read_timeout 300; + proxy_send_timeout 300; + proxy_pass http://127.0.0.1:3012; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + + # A report's receipt and, once published, its files. The web role decides + # what may be served and hands it to /report-files/ below. + location ^~ /api/v1/reports/ { + limit_req zone=boards_search burst=20 nodelay; + limit_req_status 429; + gzip on; + gzip_proxied any; + gzip_types application/json; + proxy_pass http://127.0.0.1:3012; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + + # Which catalogue boards ipctool's output may be from. Stores nothing. + location = /api/v1/boards/identify { + client_max_body_size 256k; + limit_req zone=boards_search burst=20 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:3012; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + } + + # The reports' files, content-addressed. Internal: reachable only through + # the web role's X-Accel-Redirect, which sends a published report's + # shareable files and never a private backup. Never alias this directory + # anywhere else. + location ^~ /report-files/ { + internal; + alias /srv/www/shared/dev-owner-reports/; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By go always; + add_header X-Content-Type-Options nosniff always; + } + # Where OpenIPC/xmupdates and OpenIPC/coupler push the firmware they # publish for Xiongmai devices, their whole list each time (service/ # internal/vendorfw/PUSH.md). The web role verifies the OIDC token itself. diff --git a/deploy/purge-snapshots.sh b/deploy/purge-snapshots.sh index 77a1c2f8..8b3bc2c4 100755 --- a/deploy/purge-snapshots.sh +++ b/deploy/purge-snapshots.sh @@ -58,5 +58,15 @@ if running openipc-go-web-prod; then || log "WARNING: the probe found trouble (above)" fi +# Owner reports are never purged; this only proves they are all still there. +# Every file their rows name is re-read and hashed; one missing or changed +# fails the run (service/internal/reports). +for env_name in prod dev; do + if running "openipc-go-web-${env_name}"; then + docker exec "openipc-go-web-${env_name}" openipc reports verify 2>&1 | tail -3 \ + || { log "WARNING: owner report files are missing or changed (${env_name})"; status=1; } + fi +done + log "purge complete" exit "$status" diff --git a/deploy/refresh-dev.sh b/deploy/refresh-dev.sh index ef698f8f..faaa83f3 100755 --- a/deploy/refresh-dev.sh +++ b/deploy/refresh-dev.sh @@ -113,6 +113,19 @@ find "$DEV_WALL" -mindepth 1 -maxdepth 1 -exec rm -rf {} + cp -al "$PROD_WALL/." "$DEV_WALL/" || fail "could not link production's frames into ${DEV_WALL}" log "wall linked: $(find "$DEV_WALL" -mindepth 1 -maxdepth 1 -type d | wc -l) snapshot directories" +# ------------------------------------------------------- owner reports +# The restored rows name production's report files. Linked the same way, and +# never removed: the store is content-addressed and written once, so dev's +# tree only gains names, and a file dev already has is the same bytes. +PROD_REPORTS=/srv/www/shared/owner-reports +DEV_REPORTS=/srv/www/shared/dev-owner-reports +if [ -d "$PROD_REPORTS/sha256" ]; then + [ "$(stat -c %d "$PROD_REPORTS")" = "$(stat -c %d "$DEV_REPORTS")" ] \ + || fail "${PROD_REPORTS} and ${DEV_REPORTS} are on different filesystems; hard links would be copies" + cp -aln "$PROD_REPORTS/sha256" "$DEV_REPORTS/" || fail "could not link production's owner report files into ${DEV_REPORTS}" + log "owner report files linked: $(find "$DEV_REPORTS/sha256" -type f | wc -l)" +fi + # ------------------------------------------------------------- migrate # The restored schema is production's as of last night, and dev usually runs # a branch ahead of it: `serve` refuses a database behind its binary, so diff --git a/deploy/static/reserved-paths b/deploy/static/reserved-paths index 6b27136f..6a42b3cd 100644 --- a/deploy/static/reserved-paths +++ b/deploy/static/reserved-paths @@ -52,6 +52,8 @@ /firmware-cache/ # The board catalogue's files (firmware#659), written by the web role's import. /board-files/ +# Owner reports' files, internal: reached only through the web role's X-Accel-Redirect for a published report. +/report-files/ # --- the expensive leaf ------------------------------------------------------ # /cameras/ is deliberately NOT reserved: the catalogue is exactly what #162 diff --git a/service/README.md b/service/README.md index 8dd50c63..ddfc4220 100644 --- a/service/README.md +++ b/service/README.md @@ -28,6 +28,7 @@ key the wall JSON signs them with. | `probe` | the numbers only a probe sees: all-digit `public_id`s, HEIF uploads, a stuck variant queue | | `builds import-history [--keep 90] [--kconfig-all] [--skip-builder]` | once per environment: the builds GitHub still holds, into PostgreSQL | | `boards import-openhisiipcam [--from ]` | once per environment: the OpenHisiIpCam board archive (firmware#659, pinned commit) into the board catalogue, its files under `BOARDS_ROOT`; a second run adds nothing. Run it in the web container, which mounts that directory | +| `reports list\|show\|publish\|reject\|link\|unlink\|takedown\|verify` | the owner reports' review queue (`internal/reports`): nothing a camera owner or an agent sends is public until `publish`; `takedown` is the one way a report's content is ever removed; `verify` re-hashes every stored file and runs nightly | | `routes --json` | the routes table | | `version` | the commit the binary was built from | @@ -93,6 +94,21 @@ The goldens below are fixed: nothing regenerates them. publishes, the old image and tarball are deleted, both on the index change and by the nightly purge. Errors are a page, never a flash cookie. +- **Owner reports are never lost to a rebuild.** What people and agents send + (`POST /api/v1/reports`: ipctool's output, a flash backup, photos, console + captures) exists once, on this host, so it is kept apart from everything + the board importers rewrite: its own tables, which no other package may + name (`deploytest`); triggers that refuse UPDATE, DELETE and TRUNCATE + unless `openipc reports takedown|unlink` stood them down for its own + transaction; a link to a board model that is `ON DELETE RESTRICT`; files + content-addressed under `REPORTS_ROOT`, written once, beside + `BOARDS_ROOT`; and a test that runs every importer, push and purge over + stored reports and requires them byte-identical afterwards + (`internal/boards/survival_test.go`). Each file goes to S3 the night after + it arrives. The public copy of a report has the MAC, die ID and cloud ID + replaced by keyed hashes; a backup is served only if its owner sent + `consent=public`. + ## Operating it - `deploy/install-go-service.sh`: PostgreSQL, the two databases, and diff --git a/service/cmd/openipc/main.go b/service/cmd/openipc/main.go index db008a0f..589b7731 100644 --- a/service/cmd/openipc/main.go +++ b/service/cmd/openipc/main.go @@ -9,6 +9,7 @@ // openipc builds import-history once: the builds GitHub still holds, into PostgreSQL // openipc vendor-firmware import-history once: xmupdates and coupler as published so far // openipc boards import-openhisiipcam once: the OpenHisiIpCam board archive, into the board catalogue +// openipc reports list|show|publish|reject|link|unlink|takedown|verify the owner reports' review queue // openipc routes --json what this binary answers, for the nginx seam test // // Configuration is the environment; see internal/config. @@ -40,6 +41,7 @@ import ( "github.com/OpenIPC/website/service/internal/firmware" "github.com/OpenIPC/website/service/internal/httpx" "github.com/OpenIPC/website/service/internal/purge" + "github.com/OpenIPC/website/service/internal/reports" "github.com/OpenIPC/website/service/internal/snapshots" "github.com/OpenIPC/website/service/internal/variants" "github.com/OpenIPC/website/service/internal/vendorfw" @@ -79,6 +81,8 @@ func main() { err = vendorFirmwareCommand(ctx, cfg, log, args) case "boards": err = boardsCommand(ctx, cfg, log, args) + case "reports": + err = reportsCommand(ctx, cfg, log, args) case "routes": err = printRoutes() case "version": @@ -92,7 +96,7 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: openipc serve --role web|firmware | migrate | purge [--snapshots] [--firmware] [--builds] | probe | builds import-history | boards import-openhisiipcam | boards import-snapshot | vendor-firmware import-history | routes --json | version") + fmt.Fprintln(os.Stderr, "usage: openipc serve --role web|firmware | migrate | purge [--snapshots] [--firmware] [--builds] | probe | builds import-history | boards import-openhisiipcam | boards import-snapshot | reports list|show|publish|reject|link|unlink|takedown|verify | vendor-firmware import-history | routes --json | version") os.Exit(2) } @@ -159,6 +163,10 @@ var routes = []Route{ {"web", "GET", "/api/v1/boards/models/{id}"}, {"web", "POST", "/api/v1/vendor-firmware"}, {"web", "GET", "/api/v1/vendor-firmware/{deviceId}"}, + {"web", "POST", "/api/v1/reports"}, + {"web", "GET", "/api/v1/reports/{id}"}, + {"web", "GET", "/api/v1/reports/{id}/files/{position}"}, + {"web", "POST", "/api/v1/boards/identify"}, {"firmware", "GET", "/cameras/vendors/{vendor}/socs/{soc}/download_full_image"}, {"firmware", "GET", "/{locale}/cameras/vendors/{vendor}/socs/{soc}/download_full_image"}, } @@ -347,6 +355,12 @@ func web(ctx context.Context, cfg *config.Config, log *slog.Logger, pool *pgxpoo for k, h := range (&vendorfw.API{DB: pool, Log: log}).Handlers() { handlers[k] = h } + // Owner reports (internal/reports): uploaded by anyone, public after + // review, and kept apart from everything the board importers touch. + for k, h := range (&reports.API{DB: pool, Files: &reports.Files{Root: cfg.ReportsRoot}, + AccelPrefix: cfg.ReportsAccelPrefix, Log: log}).Handlers() { + handlers[k] = h + } for _, r := range routes { if r.Role != "web" { continue diff --git a/service/cmd/openipc/reports.go b/service/cmd/openipc/reports.go new file mode 100644 index 00000000..3523bcc1 --- /dev/null +++ b/service/cmd/openipc/reports.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log/slog" + "os" + "os/user" + + "github.com/OpenIPC/website/service/internal/config" + "github.com/OpenIPC/website/service/internal/reports" +) + +const reportsUsage = `usage: openipc reports + list [--status pending|published|rejected|withdrawn] the review queue, newest first (JSON) + show the report whole, as sent: YAML, identifiers' hashes, files + publish [--model ]... [--by who] [--note text] + reject [--by who] [--note text] + link [--by who] say which board a report is from + unlink undo a link made in error + takedown --by who --note why withdraw for good: YAML blanked, files deleted + verify re-hash every stored file; non-zero on any missing or changed` + +// reportsCommand is `openipc reports ...`: the review queue for owner +// reports. There is no admin page (#288); a maintainer runs these in the web +// container. +func reportsCommand(ctx context.Context, cfg *config.Config, log *slog.Logger, args []string) error { + if len(args) == 0 { + return errors.New(reportsUsage) + } + pool, err := open(ctx, cfg) + if err != nil { + return err + } + defer pool.Close() + st := &reports.Store{DB: pool} + files := &reports.Files{Root: cfg.ReportsRoot} + + fs := flag.NewFlagSet("reports "+args[0], flag.ExitOnError) + by := fs.String("by", whoami(), "who decided") + note := fs.String("note", "", "why, for the record") + status := fs.String("status", "", "list only reports in this state") + var models multi + fs.Var(&models, "model", "the board model id the report is from (repeatable)") + cmd := args[0] + pos, err := parseInterleaved(fs, args[1:]) + if err != nil { + return err + } + need := func(n int) error { + if len(pos) != n { + return errors.New(reportsUsage) + } + return nil + } + out := json.NewEncoder(os.Stdout) + out.SetIndent("", " ") + + switch cmd { + case "list": + l, err := st.List(ctx, *status) + if err != nil { + return err + } + return out.Encode(l) + case "show": + if err := need(1); err != nil { + return err + } + r, err := st.Private(ctx, pos[0]) + if err != nil { + return err + } + _, facts, _ := reports.Parse(r.YAML) + id, err := reports.Identify(ctx, pool, facts) + if err != nil { + return err + } + fmt.Printf("# %s %s %s backup: %s\n# tool: %s\n# note: %s\n# identifiers (keyed): %v\n", + r.ID, r.ReceivedAt.Format("2006-01-02 15:04 MST"), r.Channel, r.Consent, r.Tool, r.Note, r.IDHashes) + for _, f := range r.Files { + served := "private" + if f.PublicSHA256 != "" { + served = "public " + f.PublicSHA256[:12] + } + fmt.Printf("# file %d: %s %s %d bytes sha256 %s (%s)\n", f.Position, f.Kind, f.Name, f.Bytes, f.SHA256[:12], served) + } + fmt.Println(r.YAML) + fmt.Println("# identify:") + return out.Encode(id) + case "publish", "reject": + if err := need(1); err != nil { + return err + } + for _, m := range models { + if err := st.Link(ctx, pos[0], m, *by); err != nil { + return fmt.Errorf("link %s: %w", m, err) + } + } + if err := st.Review(ctx, pos[0], cmd, *by, *note); err != nil { + return err + } + log.Info("reports: reviewed", "report", pos[0], "decision", cmd, "by", *by, "models", []string(models)) + return nil + case "link": + if err := need(2); err != nil { + return err + } + return st.Link(ctx, pos[0], pos[1], *by) + case "unlink": + if err := need(2); err != nil { + return err + } + return st.Unlink(ctx, pos[0], pos[1]) + case "takedown": + if err := need(1); err != nil { + return err + } + if *note == "" { + return errors.New("takedown needs --note: who asked, and why") + } + orphans, err := st.Takedown(ctx, pos[0], *by, *note) + if err != nil { + return err + } + for _, s := range orphans { + if err := files.Remove(s); err != nil { + return err + } + } + log.Info("reports: taken down", "report", pos[0], "by", *by, "files_deleted", len(orphans)) + return nil + case "verify": + checked, bad, err := reports.Verify(ctx, st, files) + if err != nil { + return err + } + log.Info("reports: verify", "files", checked, "bad", len(bad)) + if len(bad) > 0 { + return fmt.Errorf("%d of %d report files are missing or changed: %v", len(bad), checked, bad) + } + return nil + } + return errors.New(reportsUsage) +} + +// parseInterleaved lets flags follow the positional arguments +// (`publish r-abc --model x`), which flag.Parse alone stops at. +func parseInterleaved(fs *flag.FlagSet, args []string) ([]string, error) { + var pos []string + for { + if err := fs.Parse(args); err != nil { + return nil, err + } + args = fs.Args() + if len(args) == 0 { + return pos, nil + } + pos = append(pos, args[0]) + args = args[1:] + } +} + +type multi []string + +func (m *multi) String() string { return fmt.Sprint([]string(*m)) } +func (m *multi) Set(v string) error { *m = append(*m, v); return nil } + +func whoami() string { + if u, err := user.Current(); err == nil && u.Username != "" { + return u.Username + } + return "maintainer" +} diff --git a/service/deploytest/boards_test.go b/service/deploytest/boards_test.go index fea5c78c..33629bca 100644 --- a/service/deploytest/boards_test.go +++ b/service/deploytest/boards_test.go @@ -52,7 +52,7 @@ func TestBoardCatalogueServing(t *testing.T) { } deploy := read(t, "deploy/deploy.sh") mustContain(t, deploy, `ensure_uid_1000_root "$boards_root"`, "deploy.sh does not create the boards directory owned by uid 1000") - for _, d := range []string{"/srv/www/shared/boards\"", "/srv/www/shared/dev-boards\""} { + for _, d := range []string{"/srv/www/shared/boards ", "/srv/www/shared/dev-boards "} { mustContain(t, deploy, d, "deploy.sh's target_for does not name "+d) } mustContain(t, read(t, "deploy/install-go-service.sh"), "wall dev-wall boards dev-boards", "the installer does not create the boards directories") diff --git a/service/deploytest/reports_test.go b/service/deploytest/reports_test.go new file mode 100644 index 00000000..eefac9f4 --- /dev/null +++ b/service/deploytest/reports_test.go @@ -0,0 +1,141 @@ +package deploytest + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Owner reports (service/internal/reports) exist once, on this host: nothing +// can rebuild them the way the board catalogue is rebuilt from its archives. +// So nothing but their own package may write them. The database refuses an +// UPDATE, DELETE or TRUNCATE unless the transaction stood the guard down; +// this refuses the code that would: a file outside internal/reports that +// names the reports' tables in SQL, stands the guard down, or touches their +// files' root is an error here, in Go, SQL and shell alike. Tests may. +func TestOnlyTheReportsPackageTouchesReports(t *testing.T) { + sql := regexp.MustCompile(`(?i)\b(from|into|update|join|truncate|table|references)\s+(only\s+)?(reports|report_files|report_reviews|report_models|report_key)\b`) + named := regexp.MustCompile(`\b(report_files|report_reviews|report_models|report_key|reports_guard)\b`) + root := regexp.MustCompile(`REPORTS_ROOT|owner-reports`) + + allowed := func(rel string) bool { + switch { + case strings.HasPrefix(rel, "service/internal/reports/"), + rel == "service/internal/db/migrations/016_reports.sql", + strings.HasSuffix(rel, "_test.go"), + strings.HasSuffix(rel, ".md"): + return true + } + return false + } + // Where the root may be named: the setting, the mounts that give the + // containers the directory, the installer and deploy that create it + // owned by uid 1000, the backup that copies it off the host, and nginx's + // internal location that sends a published file. + mayNameRoot := map[string]bool{ + "service/internal/config/config.go": true, + "deploy/docker-compose.yml": true, + "deploy/install-go-service.sh": true, + "deploy/deploy.sh": true, + "deploy/backup-db.sh": true, + "deploy/refresh-dev.sh": true, + "deploy/nginx/sites-available/org.openipc": true, + "deploy/nginx/sites-available/org.openipc.dev": true, + } + var found []string + for _, dir := range []string{"service", "deploy", "tools", "bin"} { + base := path(dir) + if _, err := os.Stat(base); err != nil { + continue + } + _ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + if d != nil && d.IsDir() && (d.Name() == "node_modules" || d.Name() == "testdata" || d.Name() == "bin" && dir == "service") { + return filepath.SkipDir + } + return nil + } + rel, _ := filepath.Rel(path("."), p) + rel = filepath.ToSlash(rel) + if allowed(rel) || !d.Type().IsRegular() { + return nil + } + raw, err := os.ReadFile(p) + if err != nil || strings.ContainsRune(string(raw[:min(len(raw), 8000)]), 0) { + return nil + } + for i, line := range strings.Split(string(raw), "\n") { + if sql.MatchString(line) || named.MatchString(line) || (root.MatchString(line) && !mayNameRoot[rel]) { + found = append(found, rel+":"+itoa(i+1)+": "+strings.TrimSpace(line)) + } + } + return nil + }) + } + if len(found) > 0 { + t.Errorf("only service/internal/reports may write owner reports or their files:\n %s", strings.Join(found, "\n ")) + } +} + +// Nothing on the host removes a report's file: no script deletes under the +// reports' root, and the backup copies it without --delete. +func TestNoScriptDeletesReportFiles(t *testing.T) { + del := regexp.MustCompile(`\b(rm|find\b.*-delete|rsync\b.*--delete|aws\s+s3\s+(rm|sync\b.*--delete))\b`) + for _, f := range []string{"deploy/backup-db.sh", "deploy/deploy.sh", "deploy/install-go-service.sh", "deploy/purge-snapshots.sh", "deploy/refresh-dev.sh"} { + raw, err := os.ReadFile(path(f)) + if err != nil { + continue + } + for i, line := range strings.Split(string(raw), "\n") { + if strings.Contains(line, "REPORTS_ROOT") || strings.Contains(line, "owner-reports") { + if del.MatchString(line) { + t.Errorf("%s:%d deletes report files: %s", f, i+1, strings.TrimSpace(line)) + } + } + } + } +} + +// Where owner reports live on the host: their own directory, mounted into +// the web role, created owned by uid 1000, backed up, restorable, and served +// by nginx only from an internal location. Never /srv/www/shared/reports, +// which is the analytics' and which dev serves with autoindex. +func TestOwnerReportsHaveTheirOwnDirectoryAndOnlyAnInternalLocation(t *testing.T) { + compose := read(t, "deploy/docker-compose.yml") + for _, m := range []string{"- /srv/www/shared/owner-reports:/srv/owner-reports", "- /srv/www/shared/dev-owner-reports:/srv/owner-reports"} { + mustContain(t, compose, m, "the web container does not mount "+m) + } + deploy := read(t, "deploy/deploy.sh") + mustContain(t, deploy, `ensure_uid_1000_root "$reports_root"`, "deploy.sh does not create the owner reports' directory owned by uid 1000") + mustContain(t, read(t, "deploy/install-go-service.sh"), "owner-reports dev-owner-reports", "the installer does not create the owner reports' directories") + backup := read(t, "deploy/backup-db.sh") + mustContain(t, backup, "REPORTS_ROOT=/srv/www/shared/owner-reports", "the backup leaves out the owner reports' files, which exist nowhere else") + mustContain(t, read(t, "deploy/purge-snapshots.sh"), "openipc reports verify", "nothing checks nightly that every report file is still there") + mustContain(t, read(t, "deploy/RESTORE.md"), "Restore owner reports' files", "RESTORE.md does not say how to bring report files back") + + for _, v := range []struct{ file, root, port string }{ + {"deploy/nginx/sites-available/org.openipc", "/srv/www/shared/owner-reports/", "3002"}, + {"deploy/nginx/sites-available/org.openipc.dev", "/srv/www/shared/dev-owner-reports/", "3012"}, + } { + conf := read(t, v.file) + files := block(conf, "location ^~ /report-files/ {") + mustContain(t, files, "internal;", v.file+": /report-files/ is reachable from outside") + mustContain(t, files, "alias "+v.root+";", v.file+": /report-files/ is not the owner reports' directory") + if n := strings.Count(conf, "owner-reports"); n != 1 { + t.Errorf("%s names the owner reports' directory %d times; only the internal location may", v.file, n) + } + // On port 443 and on port 80: ipctool on stock firmware has no TLS. + if n := strings.Count(conf, "location = /api/v1/reports {"); n != 2 { + t.Errorf("%s answers the upload in %d servers, want 2 (80 and 443)", v.file, n) + } + up := block(conf, "location = /api/v1/reports {") + mustContain(t, up, "client_max_body_size 300m;", v.file+": the upload's body limit is not the backup's") + mustContain(t, up, "proxy_request_buffering off;", v.file+": nginx buffers a whole backup before the web role sees it") + mustContain(t, up, "proxy_pass http://127.0.0.1:"+v.port+";", v.file+": the upload does not reach the web role") + } + dc := read(t, "deploy/nginx/conf.d/openipc-datacentre-block.conf") + mustContain(t, dc, `"1:/api/v1/reports" 0;`, "an agent in a cloud cannot send a report") +} diff --git a/service/internal/boards/export_test.go b/service/internal/boards/export_test.go new file mode 100644 index 00000000..2958746a --- /dev/null +++ b/service/internal/boards/export_test.go @@ -0,0 +1,28 @@ +package boards + +import ( + "testing" + "testing/fstest" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// For the reports' survival test (survival_test.go, package boards_test), +// which imports internal/reports and so cannot live in package boards: the +// fixtures every importer test here uses. + +func Imported(t *testing.T) (*pgxpool.Pool, string) { return imported(t) } + +func Donor(t *testing.T, id string, models ...map[string]any) fstest.MapFS { + return donor(t, id, models...) +} + +func DonorModel(maker, code string, extra map[string]any) map[string]any { + return model(maker, code, extra) +} + +func Supported(label string) string { return supported(label) } + +func Archive() fstest.MapFS { return archive() } + +func JPG(w, h int) []byte { return jpg(w, h) } diff --git a/service/internal/boards/survival_test.go b/service/internal/boards/survival_test.go new file mode 100644 index 00000000..c5b207c6 --- /dev/null +++ b/service/internal/boards/survival_test.go @@ -0,0 +1,228 @@ +package boards_test + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "io/fs" + "log/slog" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/OpenIPC/website/service/internal/boards" + "github.com/OpenIPC/website/service/internal/builds" + "github.com/OpenIPC/website/service/internal/db" + "github.com/OpenIPC/website/service/internal/purge" + "github.com/OpenIPC/website/service/internal/reports" + "github.com/OpenIPC/website/service/internal/vendorfw" +) + +// Owner reports survive everything that rebuilds the board catalogue. +// +// Reports linked to a board from the OpenHisiIpCam archive and to boards +// every donor describes are stored, with a backup, a photo and a boot log; +// then every writer the catalogue has runs over them, twice, the second time +// with changed files so that refreshUnit deletes artifacts and removes stale +// files: the archive import, a snapshot from each donor source (cctvsp, +// xiongmai, tehno32, jftech, anjoy), a push from each vendor-firmware +// source, the nightly purge and trim, and the migrations. Afterwards every +// report row, review and link is as it was, and every file is on disk with +// the bytes its name says. +func TestOwnerReportsSurviveEveryCatalogueWriter(t *testing.T) { + pool, boardsRoot := boards.Imported(t) + ctx := context.Background() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + im := &boards.Importer{Pool: pool, Log: log, Root: boardsRoot, Resolve: boards.Supported} + + sources := []string{"cctvsp", "xiongmai", "tehno32", "jftech", "anjoy"} + snapshot := func(src string, round int) fstest.MapFS { + m := boards.DonorModel("xiongmai", "IPG-50H20L-S", nil) + if src == "anjoy" { + m = boards.DonorModel("anjoy", "MC-A3", nil) + } + snap := boards.Donor(t, src, m) + if round == 2 { + // a changed photo, so the donor's unit is refreshed and its old + // files removed from BOARDS_ROOT + snap["files/a.jpg"] = &fstest.MapFile{Data: boards.JPG(320, 240)} + } + return snap + } + for _, src := range sources { + if _, err := im.FromSnapshot(ctx, snapshot(src, 1)); err != nil { + t.Fatalf("%s: %v", src, err) + } + } + + files := &reports.Files{Root: t.TempDir()} + api := &reports.API{DB: pool, Files: files, AccelPrefix: "/report-files/", Log: log} + mux := http.NewServeMux() + for k, h := range api.Handlers() { + mux.Handle(k, h) + } + var ids []string + var models []string + if err := pool.QueryRow(ctx, `SELECT array_agg(id ORDER BY id) FROM board_models`).Scan(&models); err != nil { + t.Fatal(err) + } + linked := []string{models[0], "xiongmai-ipg-50h20l-s", "anjoy-mc-a3"} + for i, model := range linked { + id := upload(t, mux, map[string][]byte{ + "backup": backup(xmYAML, bytes.Repeat([]byte{byte(i)}, 4096)), + "photo": boards.JPG(64+i, 48), + "boot_log": []byte("U-Boot 2010.06\nethaddr=00:12:89:12:88:e1\n"), + }, map[string]string{"consent": []string{"private", "public", "private"}[i]}, fmt.Sprintf("203.0.113.%d", i+1)) + st := &reports.Store{DB: pool} + if err := st.Link(ctx, id, model, "test"); err != nil { + t.Fatalf("link %s: %v", model, err) + } + if err := st.Review(ctx, id, "publish", "test", ""); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + before := state(t, pool, files) + + for round := 1; round <= 2; round++ { + if _, err := im.FromFS(ctx, boards.Archive()); err != nil { + t.Fatal(err) + } + for _, src := range sources { + if _, err := im.FromSnapshot(ctx, snapshot(src, round)); err != nil { + t.Fatalf("round %d, %s: %v", round, src, err) + } + } + at := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + for src, item := range map[string]vendorfw.Item{ + "xmupdates": {Key: "x.bin", DeviceID: "000559A7", Version: "1", Build: "b", + AssetURL: vendorfw.Sources["xmupdates"] + "latest/x.bin"}, + "coupler": {Key: "c.bin", DeviceID: "000559A7", Version: "1", Build: "b", + AssetURL: vendorfw.Sources["coupler"] + "latest/c.bin"}, + "anjoyupdates": {Key: "a1", Version: "3.6", Build: "MC-A3_V0", DeviceType: "MC-A3_V0", App: "public", Category: "camera", + AssetURL: vendorfw.Sources["anjoyupdates"] + "firmware-archive/a1.bin", SHA256: strings.Repeat("e", 64), Size: 100, PublishedAt: &at}, + } { + if _, err := vendorfw.Save(ctx, pool, &vendorfw.Payload{Schema: 1, Source: src, Items: []vendorfw.Item{item}}, "test"); err != nil { + t.Fatalf("%s: %v", src, err) + } + } + list, err := boards.Confirmations() + if err != nil { + t.Fatal(err) + } + if _, err := boards.ApplyConfirmations(ctx, pool, list); err != nil { + t.Fatal(err) + } + p := &purge.Snapshots{DB: pool, WallRoot: t.TempDir(), MaxAge: 48 * time.Hour, Log: log} + if _, _, err := p.Run(ctx); err != nil { + t.Fatal(err) + } + if _, err := builds.Trim(ctx, pool, 90); err != nil { + t.Fatal(err) + } + if _, err := db.Migrate(ctx, pool); err != nil { + t.Fatal(err) + } + } + + after := state(t, pool, files) + if before != after { + t.Errorf("the reports changed.\nbefore: %s\nafter: %s", before, after) + } + if !strings.Contains(after, ids[0]) || strings.Count(after, `"decision":"publish"`) != 3 { + t.Errorf("the state does not hold the three published reports: %s", after) + } + checked, bad, err := reports.Verify(ctx, &reports.Store{DB: pool}, files) + if err != nil || len(bad) != 0 || checked == 0 { + t.Errorf("verify after the imports: %d checked, bad %v, %v", checked, bad, err) + } +} + +// state is every report row, review and link, and every stored file's name, +// size and mode, as one string. +func state(t *testing.T, pool *pgxpool.Pool, files *reports.Files) string { + t.Helper() + ctx := context.Background() + var out []string + for _, q := range []string{ + `SELECT json_agg(r ORDER BY id) FROM reports r`, + `SELECT json_agg(f ORDER BY report_id, position) FROM report_files f`, + `SELECT json_agg(v ORDER BY id) FROM report_reviews v`, + `SELECT json_agg(m ORDER BY report_id, model_id) FROM report_models m`, + `SELECT json_agg(k) FROM report_key k`, + } { + var s string + if err := pool.QueryRow(ctx, q).Scan(&s); err != nil { + t.Fatalf("%s: %v", q, err) + } + out = append(out, s) + } + _ = filepath.WalkDir(filepath.Join(files.Root, "sha256"), func(p string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() { + info, _ := d.Info() + out = append(out, fmt.Sprintf("%s %s %d", d.Name(), info.Mode(), info.Size())) + } + return nil + }) + return strings.Join(out, "\n") +} + +func upload(t *testing.T, mux *http.ServeMux, parts map[string][]byte, fields map[string]string, ip string) string { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + for k, v := range fields { + _ = mw.WriteField(k, v) + } + for k, v := range parts { + w, _ := mw.CreateFormFile(k, k+".bin") + w.Write(v) + } + mw.Close() + req := httptest.NewRequest("POST", "/api/v1/reports", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.RemoteAddr = ip + ":1" + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + var out struct { + ID string `json:"id"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if rec.Code != http.StatusCreated || out.ID == "" { + t.Fatalf("upload: %d %s", rec.Code, rec.Body) + } + return out.ID +} + +func backup(yaml string, flash []byte) []byte { + var b bytes.Buffer + b.WriteString(yaml) + b.WriteByte(0) + _ = binary.Write(&b, binary.LittleEndian, uint32(len(flash))) + b.Write(flash) + return b.Bytes() +} + +const xmYAML = `board: + vendor: Xiongmai + model: 50H20L + cloudId: 3beae2b40d84f889 +chip: + vendor: HiSilicon + model: 3516CV300 +ethernet: + mac: "00:12:89:12:88:e1" +sensors: +- vendor: Sony + model: IMX291 +` diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 8369fa6f..80c1e60e 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -31,6 +31,12 @@ type Config struct { GrantsDisabled bool // WALL_GRANTS_DISABLED=1: the frame socket serves without grants (the emergency switch) SnapshotMaxAge time.Duration BoardsRoot string // the board catalogue's files, served by nginx at /board-files/ + // Owner reports' files, content-addressed and written once; never inside + // BoardsRoot, and not the analytics' /srv/www/shared/reports either + // (internal/reports). Served only through the web role, which + // hands a published file to nginx's internal location ReportsAccelPrefix. + ReportsRoot string + ReportsAccelPrefix string // Firmware role. CatalogueDir string @@ -58,6 +64,8 @@ func Load() (*Config, error) { GrantsDisabled: os.Getenv("WALL_GRANTS_DISABLED") == "1", SnapshotMaxAge: 48 * time.Hour, BoardsRoot: str("BOARDS_ROOT", "/srv/boards"), + ReportsRoot: str("REPORTS_ROOT", "/srv/owner-reports"), + ReportsAccelPrefix: str("REPORTS_ACCEL_PREFIX", "/report-files/"), CatalogueDir: str("CATALOGUE_DIR", "/app/catalogue"), ReleaseCacheRoot: str("RELEASE_CACHE_ROOT", "/srv/release-cache"), FirmwareCacheRoot: str("FIRMWARE_CACHE_ROOT", "/srv/firmware"), diff --git a/service/internal/db/migrations/016_reports.sql b/service/internal/db/migrations/016_reports.sql new file mode 100644 index 00000000..2ac9081c --- /dev/null +++ b/service/internal/db/migrations/016_reports.sql @@ -0,0 +1,129 @@ +-- Owner reports: what a camera's owner, or an AI coding agent working on a +-- bench, sends about a board -- ipctool's YAML, optionally a full-flash +-- backup, photos, a boot log, the U-Boot environment. Uploaded anonymously +-- (POST /api/v1/reports), stored at once, public only after review. +-- +-- A report is the one thing in the catalogue nobody can re-create: the board +-- catalogue's other rows are rebuilt from pinned archives, a report exists +-- once, on this host. So it is kept apart from everything an import touches, +-- and a mistake elsewhere cannot reach it: +-- +-- * Its own tables. No importer names them (service/deploytest fails if +-- one does), and nothing cascades into them: the link to a board model is +-- ON DELETE RESTRICT, so deleting a model that has a report fails instead +-- of taking the report with it (migration 008 deleted models by hand). +-- * Triggers refuse UPDATE, DELETE and TRUNCATE outright. The one way past +-- them is `openipc reports takedown|link`, which sets +-- openipc.reports_guard = 'off' for its own transaction only. +-- * Its files are content-addressed under REPORTS_ROOT, beside +-- BOARDS_ROOT and never inside it, written once and never overwritten. + +CREATE TABLE reports ( + id text PRIMARY KEY CHECK (id ~ '^r-[a-z0-9]{8}$'), + received_at timestamptz NOT NULL DEFAULT now(), + channel text NOT NULL CHECK (channel IN ('ipctool', 'agent', 'web')), + tool text NOT NULL DEFAULT '' CHECK (length(tool) <= 200), + note text NOT NULL DEFAULT '' CHECK (length(note) <= 4000), + -- the YAML as sent, and the copy with the board's identifiers replaced + -- by keyed hashes -- the only one ever served + yaml text NOT NULL, + yaml_public text NOT NULL, + yaml_sha256 text NOT NULL CHECK (yaml_sha256 ~ '^[0-9a-f]{64}$'), + -- what the YAML says, for matching and listing + chip_vendor text NOT NULL DEFAULT '', + chip_model text NOT NULL DEFAULT '', + sensor text NOT NULL DEFAULT '', + flash_id text NOT NULL DEFAULT '', + flash_size text NOT NULL DEFAULT '', + board_vendor text NOT NULL DEFAULT '', + board_model text NOT NULL DEFAULT '', + main_app text NOT NULL DEFAULT '', + -- keyed hashes of MAC, die ID and cloud ID: two reports from one board + -- match without either identifier being stored twice or served + id_hashes jsonb NOT NULL DEFAULT '{}', + backup_consent text NOT NULL CHECK (backup_consent IN ('none', 'private', 'public')), + client_hash text NOT NULL +); +CREATE INDEX reports_by_client ON reports (client_hash, received_at); +CREATE INDEX reports_by_chip ON reports (lower(chip_model)); + +CREATE TABLE report_files ( + report_id text NOT NULL REFERENCES reports ON DELETE RESTRICT, + position int NOT NULL, + kind text NOT NULL CHECK (kind IN ('backup', 'photo', 'boot_log', 'uboot_env', 'note', 'document')), + name text NOT NULL CHECK (name ~ '^[A-Za-z0-9._-]{1,120}$'), + mime text NOT NULL, + sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + bytes bigint NOT NULL CHECK (bytes > 0), + -- the copy served once the report is published: the file itself, or for + -- text a copy with the identifiers replaced. NULL: never served (a + -- backup its owner did not agree to share). + public_sha256 text CHECK (public_sha256 ~ '^[0-9a-f]{64}$'), + public_bytes bigint, + PRIMARY KEY (report_id, position) +); +CREATE INDEX report_files_by_sha256 ON report_files (sha256); +CREATE INDEX report_files_by_public_sha256 ON report_files (public_sha256); + +-- A report's state is its newest review; no review is "pending". +CREATE TABLE report_reviews ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + report_id text NOT NULL REFERENCES reports ON DELETE RESTRICT, + decision text NOT NULL CHECK (decision IN ('publish', 'reject', 'withdraw')), + by text NOT NULL CHECK (by <> ''), + at timestamptz NOT NULL DEFAULT now(), + note text NOT NULL DEFAULT '' +); +CREATE INDEX report_reviews_by_report ON report_reviews (report_id, id DESC); + +CREATE TABLE report_models ( + report_id text NOT NULL REFERENCES reports ON DELETE RESTRICT, + model_id text NOT NULL REFERENCES board_models ON DELETE RESTRICT ON UPDATE RESTRICT, + by text NOT NULL CHECK (by <> ''), + at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (report_id, model_id) +); +CREATE INDEX report_models_by_model ON report_models (model_id); + +-- The key for id_hashes and client_hash, made once per database. It is +-- backed up with the rows it keys; nothing else needs it. +CREATE TABLE report_key ( + one boolean PRIMARY KEY DEFAULT true CHECK (one), + key text NOT NULL +); +INSERT INTO report_key (key) +VALUES (replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', '')); + +CREATE FUNCTION reports_guard() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF coalesce(current_setting('openipc.reports_guard', true), '') <> 'off' THEN + RAISE EXCEPTION 'owner reports are never changed or deleted (% on %); see service/internal/reports', TG_OP, TG_TABLE_NAME + USING ERRCODE = 'insufficient_privilege'; + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END $$; + +CREATE TRIGGER reports_guard BEFORE UPDATE OR DELETE ON reports + FOR EACH ROW EXECUTE FUNCTION reports_guard(); +CREATE TRIGGER report_files_guard BEFORE UPDATE OR DELETE ON report_files + FOR EACH ROW EXECUTE FUNCTION reports_guard(); +CREATE TRIGGER report_reviews_guard BEFORE UPDATE OR DELETE ON report_reviews + FOR EACH ROW EXECUTE FUNCTION reports_guard(); +CREATE TRIGGER report_models_guard BEFORE UPDATE OR DELETE ON report_models + FOR EACH ROW EXECUTE FUNCTION reports_guard(); +CREATE TRIGGER report_key_guard BEFORE UPDATE OR DELETE ON report_key + FOR EACH ROW EXECUTE FUNCTION reports_guard(); + +CREATE TRIGGER reports_no_truncate BEFORE TRUNCATE ON reports + FOR EACH STATEMENT EXECUTE FUNCTION reports_guard(); +CREATE TRIGGER report_files_no_truncate BEFORE TRUNCATE ON report_files + FOR EACH STATEMENT EXECUTE FUNCTION reports_guard(); +CREATE TRIGGER report_reviews_no_truncate BEFORE TRUNCATE ON report_reviews + FOR EACH STATEMENT EXECUTE FUNCTION reports_guard(); +CREATE TRIGGER report_models_no_truncate BEFORE TRUNCATE ON report_models + FOR EACH STATEMENT EXECUTE FUNCTION reports_guard(); +CREATE TRIGGER report_key_no_truncate BEFORE TRUNCATE ON report_key + FOR EACH STATEMENT EXECUTE FUNCTION reports_guard(); diff --git a/service/internal/reports/api_test.go b/service/internal/reports/api_test.go new file mode 100644 index 00000000..bf65d667 --- /dev/null +++ b/service/internal/reports/api_test.go @@ -0,0 +1,357 @@ +package reports + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/OpenIPC/website/service/internal/db/dbtest" +) + +func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +type env struct { + pool *pgxpool.Pool + api *API + mux *http.ServeMux +} + +func newEnv(t *testing.T) *env { + t.Helper() + pool := dbtest.New(t) + api := &API{DB: pool, Files: &Files{Root: t.TempDir()}, AccelPrefix: "/report-files/", Log: quiet()} + mux := http.NewServeMux() + for k, h := range api.Handlers() { + mux.Handle(k, h) + } + ctx := context.Background() + for _, sql := range []string{ + `INSERT INTO board_manufacturers (id, name) VALUES ('xiongmai', 'Xiongmai')`, + `INSERT INTO board_models (id, manufacturer_id, model, soc, soc_label) VALUES + ('xiongmai-50h20l', 'xiongmai', '50H20L', 'hi3516cv300', 'Hi3516CV300'), + ('xiongmai-other', 'xiongmai', 'IPG-OTHER', 'hi3516cv300', 'Hi3516CV300')`, + `INSERT INTO board_units (id, model_id, sensor, flash_chip, source, source_ref) VALUES + ('u1', 'xiongmai-other', 'IMX291', 'w25q128', 'contributor', 'test:1')`, + } { + if _, err := pool.Exec(ctx, sql); err != nil { + t.Fatal(err) + } + } + return &env{pool, api, mux} +} + +type upload struct { + fields map[string]string + files map[string][]byte // part name -> bytes; "photo" etc. +} + +func (e *env) post(t *testing.T, u upload, ip string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + for k, v := range u.fields { + _ = mw.WriteField(k, v) + } + for k, v := range u.files { + name := strings.SplitN(k, "#", 2)[0] + w, _ := mw.CreateFormFile(name, k+".bin") + w.Write(v) + } + mw.Close() + req := httptest.NewRequest("POST", "/api/v1/reports", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.RemoteAddr = ip + ":1234" + rec := httptest.NewRecorder() + e.mux.ServeHTTP(rec, req) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec, out +} + +func (e *env) get(t *testing.T, path string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + rec := httptest.NewRecorder() + e.mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec, out +} + +var jpeg = append([]byte{0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 'J', 'F', 'I', 'F', 0}, bytes.Repeat([]byte{7}, 2000)...) + +// The whole path: ipctool's output with a private backup, a photo and a boot +// log arrives; the receipt shows only what arrived; a maintainer publishes it +// on its board; the public report has no identifier in it anywhere, and the +// private backup cannot be fetched. +func TestAReportIsAReceiptUntilPublishedAndThenNamesNoCamera(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + yaml := fixture(t, "xiongmai-50h20l-readme.yml") + rec, out := e.post(t, upload{ + fields: map[string]string{"channel": "agent", "tool": "defib onboard 0.9"}, + files: map[string][]byte{ + "backup": backupOf(yaml, bytes.Repeat([]byte{0xff}, 8<<10)), + "photo": jpeg, + "boot_log": []byte("U-Boot 2010.06\nethaddr=00:12:89:12:88:E1\ncloud 3beae2b40d84f889\n"), + }}, "203.0.113.5") + if rec.Code != http.StatusCreated { + t.Fatalf("upload: %d %s", rec.Code, rec.Body) + } + id := out["id"].(string) + if out["backup_consent"] != "private" || !strings.HasSuffix(out["receipt_url"].(string), "/cameras/report/?id="+id) { + t.Errorf("answer: %v", out) + } + ident := out["identify"].(map[string]any) + if ident["known"] != true || ident["matches"].([]any)[0].(map[string]any)["model_id"] != "xiongmai-50h20l" { + t.Errorf("the board code was not matched: %v", ident) + } + + _, v := e.get(t, "/api/v1/reports/"+id) + if v["status"] != "pending" || v["yaml"] != nil || v["facts"] != nil { + t.Errorf("an unreviewed report shows its content: %v", v) + } + if rec, _ := e.get(t, "/api/v1/reports/"+id+"/files/2"); rec.Code != 404 { + t.Errorf("an unreviewed report's photo: %d", rec.Code) + } + + st := &Store{DB: e.pool} + if err := st.Link(ctx, id, "xiongmai-50h20l", "test"); err != nil { + t.Fatal(err) + } + if err := st.Review(ctx, id, "publish", "test", ""); err != nil { + t.Fatal(err) + } + rec, v = e.get(t, "/api/v1/reports/"+id) + whole := rec.Body.String() + if v["status"] != "published" || !strings.Contains(whole, "HiSilicon") || !strings.Contains(whole, "xiongmai-50h20l") { + t.Fatalf("published report: %s", whole) + } + for _, secret := range []string{"00:12:89:12:88:e1", "3beae2b40d84f889"} { + if strings.Contains(strings.ToLower(whole), secret) { + t.Errorf("%s is in the public report", secret) + } + } + files := v["files"].([]any) + backup, photo, log := files[0].(map[string]any), files[1].(map[string]any), files[2].(map[string]any) + if backup["private"] != true || backup["url"] != nil { + t.Errorf("the private backup is offered: %v", backup) + } + if rec, _ := e.get(t, "/api/v1/reports/"+id+"/files/1"); rec.Code != 404 { + t.Errorf("the private backup was served: %d", rec.Code) + } + rec, _ = e.get(t, photo["url"].(string)) + if rec.Code != 200 || rec.Header().Get("Content-Type") != "image/jpeg" || + !strings.HasPrefix(rec.Header().Get("X-Accel-Redirect"), "/report-files/sha256/") { + t.Errorf("photo: %d %v", rec.Code, rec.Header()) + } + // the boot log is served as its redacted copy, which is on disk + rec, _ = e.get(t, log["url"].(string)) + served := filepath.Join(e.api.Files.Root, strings.TrimPrefix(rec.Header().Get("X-Accel-Redirect"), "/report-files/")) + text, err := os.ReadFile(served) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(text)), "12:88:e1") || strings.Contains(string(text), "3beae2b40d84f889") || + !strings.Contains(string(text), "U-Boot 2010.06") { + t.Errorf("served boot log:\n%s", text) + } +} + +func TestABackupSharedPubliclyIsServedAndItsYAMLIsTheReport(t *testing.T) { + e := newEnv(t) + yaml := fixture(t, "hi3516cv300-imx291.txt") + rec, out := e.post(t, upload{fields: map[string]string{"consent": "public"}, + files: map[string][]byte{"backup": backupOf(yaml, []byte("flash"))}}, "203.0.113.6") + if rec.Code != 201 || out["facts"].(map[string]any)["chip_model"] != "3516CV300" { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + id := out["id"].(string) + _ = (&Store{DB: e.pool}).Review(context.Background(), id, "publish", "test", "") + if rec, _ := e.get(t, "/api/v1/reports/"+id+"/files/1"); rec.Code != 200 || + !strings.HasPrefix(rec.Header().Get("Content-Disposition"), "attachment") { + t.Errorf("the shared backup: %d %v", rec.Code, rec.Header()) + } +} + +func TestPlainIpctoolOutputIsAReport(t *testing.T) { + e := newEnv(t) + req := httptest.NewRequest("POST", "/api/v1/reports?channel=ipctool", strings.NewReader(fixture(t, "t31-sc2332.txt"))) + req.RemoteAddr = "203.0.113.7:1" + rec := httptest.NewRecorder() + e.mux.ServeHTTP(rec, req) + if rec.Code != 201 || !strings.Contains(rec.Body.String(), `"backup_consent": "none"`) { + t.Errorf("%d %s", rec.Code, rec.Body) + } +} + +func TestWhatIsNotAReportIsRefusedAndNothingIsKept(t *testing.T) { + e := newEnv(t) + yaml := fixture(t, "hi3516cv300-imx291.txt") + other := fixture(t, "t31-sc2332.txt") + for name, u := range map[string]upload{ + "no yaml": {files: map[string][]byte{"photo": jpeg}}, + "not ipctool": {fields: map[string]string{"yaml": "hello"}}, + "foreign backup": {fields: map[string]string{"yaml": yaml}, files: map[string][]byte{"backup": backupOf(other, []byte("x"))}}, + "broken backup": {files: map[string][]byte{"backup": []byte("chip:\n")}}, + "photo not image": {fields: map[string]string{"yaml": yaml}, files: map[string][]byte{"photo": []byte("")}}, + "unknown part": {fields: map[string]string{"yaml": yaml, "password": "x"}}, + "bad consent": {fields: map[string]string{"consent": "maybe"}, files: map[string][]byte{"backup": backupOf(yaml, []byte("x"))}}, + "bad channel": {fields: map[string]string{"yaml": yaml, "channel": "email"}}, + } { + rec, _ := e.post(t, u, "203.0.113.8") + if rec.Code < 400 || rec.Code >= 500 { + t.Errorf("%s: %d %s", name, rec.Code, rec.Body) + } + } + var n int + _ = e.pool.QueryRow(context.Background(), `SELECT count(*) FROM reports`).Scan(&n) + entries, _ := os.ReadDir(filepath.Join(e.api.Files.Root, "sha256")) + left, _ := os.ReadDir(filepath.Join(e.api.Files.Root, ".incoming")) + if n != 0 || len(entries) != 0 || len(left) != 0 { + t.Errorf("refused uploads left %d rows, %d stored and %d incoming files", n, len(entries), len(left)) + } +} + +func TestOneAddressSendsTenReportsADay(t *testing.T) { + e := newEnv(t) + yaml := fixture(t, "hi3516cv300-imx291.txt") + for i := 0; i < DailyPerClient; i++ { + if rec, _ := e.post(t, upload{fields: map[string]string{"yaml": yaml}}, "198.51.100.1"); rec.Code != 201 { + t.Fatalf("report %d: %d", i+1, rec.Code) + } + } + if rec, _ := e.post(t, upload{fields: map[string]string{"yaml": yaml}}, "198.51.100.1"); rec.Code != 429 { + t.Errorf("the eleventh: %d", rec.Code) + } + if rec, _ := e.post(t, upload{fields: map[string]string{"yaml": yaml}}, "198.51.100.2"); rec.Code != 201 { + t.Errorf("another address: %d", rec.Code) + } +} + +func TestIdentifyStoresNothing(t *testing.T) { + e := newEnv(t) + req := httptest.NewRequest("POST", "/api/v1/boards/identify", strings.NewReader(fixture(t, "hi3516cv300-imx291.txt"))) + rec := httptest.NewRecorder() + e.mux.ServeHTTP(rec, req) + var out struct { + Identify Identification `json:"identify"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if rec.Code != 200 || out.Identify.SoC != "hi3516cv300" || out.Identify.Known || + len(out.Identify.Matches) != 1 || out.Identify.Matches[0].ModelID != "xiongmai-other" { + t.Errorf("%d %s", rec.Code, rec.Body) + } + var n int + _ = e.pool.QueryRow(context.Background(), `SELECT count(*) FROM reports`).Scan(&n) + if n != 0 { + t.Errorf("identify stored %d reports", n) + } +} + +// The database itself refuses to lose a report: no UPDATE, DELETE or +// TRUNCATE outside Unguarded, and a board model with a report cannot be +// deleted -- not directly, not by a cascade. +func TestTheDatabaseRefusesToLoseAReport(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + rec, out := e.post(t, upload{fields: map[string]string{"yaml": fixture(t, "xiongmai-50h20l-readme.yml")}, + files: map[string][]byte{"photo": jpeg}}, "203.0.113.9") + if rec.Code != 201 { + t.Fatal(rec.Body) + } + id := out["id"].(string) + st := &Store{DB: e.pool} + _ = st.Link(ctx, id, "xiongmai-50h20l", "test") + _ = st.Review(ctx, id, "publish", "test", "") + for _, sql := range []string{ + `DELETE FROM reports`, + `UPDATE reports SET yaml = ''`, + `TRUNCATE reports CASCADE`, + `DELETE FROM report_files`, + `UPDATE report_files SET public_sha256 = NULL`, + `TRUNCATE report_files`, + `DELETE FROM report_reviews`, + `UPDATE report_reviews SET decision = 'reject'`, + `DELETE FROM report_models`, + `UPDATE report_key SET key = 'x'`, + `DELETE FROM board_models WHERE id = 'xiongmai-50h20l'`, + `DELETE FROM board_manufacturers WHERE id = 'xiongmai'`, + `TRUNCATE board_models CASCADE`, + `UPDATE board_models SET id = 'renamed' WHERE id = 'xiongmai-50h20l'`, + } { + if _, err := e.pool.Exec(ctx, sql); err == nil { + t.Errorf("%s: allowed", sql) + } + } + var n int + _ = e.pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM reports) + (SELECT count(*) FROM report_files) + + (SELECT count(*) FROM report_reviews) + (SELECT count(*) FROM report_models)`).Scan(&n) + if n != 4 { + t.Errorf("%d rows left of 4", n) + } + // an unrelated model still goes as before + if _, err := e.pool.Exec(ctx, `DELETE FROM board_models WHERE id = 'xiongmai-other'`); err != nil { + t.Errorf("a model without reports: %v", err) + } +} + +func TestTakedownBlanksTheReportAndDeletesFilesNoOtherReportHolds(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + yaml := fixture(t, "xiongmai-50h20l-readme.yml") + _, a := e.post(t, upload{fields: map[string]string{"yaml": yaml}, files: map[string][]byte{"photo": jpeg, "note": []byte("mine only")}}, "203.0.113.10") + _, b := e.post(t, upload{fields: map[string]string{"yaml": yaml}, files: map[string][]byte{"photo": jpeg}}, "203.0.113.10") + st := &Store{DB: e.pool} + orphans, err := st.Takedown(ctx, a["id"].(string), "test", "owner asked") + if err != nil { + t.Fatal(err) + } + // the note and its redacted copy go (one file: nothing to redact); the photo b also has stays + if len(orphans) != 1 { + t.Errorf("orphans %v", orphans) + } + _, v := e.get(t, "/api/v1/reports/"+a["id"].(string)) + if v["status"] != "withdrawn" || len(v["files"].([]any)) != 0 { + t.Errorf("withdrawn report: %v", v) + } + r, _ := st.Private(ctx, a["id"].(string)) + if r.YAML != "" || len(r.IDHashes) != 0 { + t.Errorf("the withdrawn report kept its content") + } + if br, _ := st.Private(ctx, b["id"].(string)); br.YAML == "" || len(br.Files) != 1 { + t.Errorf("the other report changed") + } + checked, bad, err := Verify(ctx, st, e.api.Files) + if err != nil || checked != 1 || len(bad) != 0 { + t.Errorf("verify: %d %v %v", checked, bad, err) + } +} + +func TestVerifyFindsAChangedFile(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + _, out := e.post(t, upload{fields: map[string]string{"yaml": fixture(t, "t31-sc2332.txt")}, files: map[string][]byte{"photo": jpeg}}, "203.0.113.11") + if out["id"] == nil { + t.Fatal(out) + } + sum := out["files"].([]any)[0].(map[string]any)["sha256"].(string) + p := filepath.Join(e.api.Files.Root, Rel(sum)) + _ = os.Chmod(p, 0o644) + if err := os.WriteFile(p, []byte("changed"), 0o644); err != nil { + t.Fatal(err) + } + if _, bad, _ := Verify(ctx, &Store{DB: e.pool}, e.api.Files); len(bad) != 1 { + t.Errorf("verify missed the change: %v", bad) + } +} diff --git a/service/internal/reports/backup.go b/service/internal/reports/backup.go new file mode 100644 index 00000000..64def220 --- /dev/null +++ b/service/internal/reports/backup.go @@ -0,0 +1,80 @@ +package reports + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" +) + +// MaxBackup bounds a full-flash backup. NOR is 8-32 MB; a 128 MB SPI NAND +// with its UBI volumes read out fits, and nothing larger is a camera. +const MaxBackup = 256 << 20 + +// maxBlocks is ipctool's MAX_MTDBLOCKS with room to spare. +const maxBlocks = 64 + +// Backup is what an ipctool backup file holds, as `ipctool backup ` and +// `ipctool upload --backup` write it (ipctool src/backup.c, save_file): the +// YAML and a NUL, then each MTD partition -- or each UBI volume of a UBI +// partition -- as a little-endian uint32 length and that many bytes. +type Backup struct { + YAML string + Blocks []int64 // each partition's length, in order +} + +// Size is the flash the backup holds. +func (b Backup) Size() int64 { + var n int64 + for _, l := range b.Blocks { + n += l + } + return n +} + +// ReadBackup checks that r is one whole backup, to its last byte, and +// returns its YAML and partition lengths. It reads r once, discarding the +// partitions' contents: the caller has already stored and hashed them. +func ReadBackup(r io.Reader) (Backup, error) { + var b Backup + br := bufio.NewReaderSize(r, MaxYAML+1) + head, err := br.ReadSlice(0) + if errors.Is(err, bufio.ErrBufferFull) { + return b, fmt.Errorf("the backup does not start with ipctool's YAML and a NUL within %d KB", MaxYAML>>10) + } + if err != nil { + return b, errors.New("the backup does not start with ipctool's YAML and a NUL") + } + b.YAML = string(bytes.TrimSuffix(head, []byte{0})) + var total int64 = int64(len(head)) + for { + var n uint32 + if err := binary.Read(br, binary.LittleEndian, &n); err != nil { + if errors.Is(err, io.EOF) { + break + } + return b, fmt.Errorf("partition %d: the length is cut short", len(b.Blocks)+1) + } + if n == 0 { + return b, fmt.Errorf("partition %d is empty", len(b.Blocks)+1) + } + if len(b.Blocks) == maxBlocks { + return b, fmt.Errorf("the backup has more than %d partitions", maxBlocks) + } + got, err := io.CopyN(io.Discard, br, int64(n)) + if err != nil { + return b, fmt.Errorf("partition %d: %d bytes of %d", len(b.Blocks)+1, got, n) + } + b.Blocks = append(b.Blocks, int64(n)) + total += 4 + int64(n) + if total > MaxBackup { + return b, fmt.Errorf("the backup is larger than %d MB", MaxBackup>>20) + } + } + if len(b.Blocks) == 0 { + return b, errors.New("the backup holds ipctool's YAML but no flash") + } + return b, nil +} diff --git a/service/internal/reports/files.go b/service/internal/reports/files.go new file mode 100644 index 00000000..c8df09f2 --- /dev/null +++ b/service/internal/reports/files.go @@ -0,0 +1,158 @@ +package reports + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "hash" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" +) + +// Files is REPORTS_ROOT: every file a report brought, named by its sha256 -- +// sha256/ab/abcdef... -- and written once. A name is its content, so a file +// is never overwritten: a second report bringing the same bytes finds them +// already there. Only Remove deletes, and only `openipc reports takedown` +// calls it. +// +// The root is beside BOARDS_ROOT, never inside it: the board importers +// remove files under BOARDS_ROOT, and nothing that does is given this path. +type Files struct { + Root string +} + +var hexSum = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// Rel is a stored file's path under the root, as nginx's internal location +// is given it. +func Rel(sum string) string { + return filepath.Join("sha256", sum[:2], sum) +} + +func (s *Files) path(sum string) string { return filepath.Join(s.Root, Rel(sum)) } + +// Incoming is a file being received: written to .incoming/ and hashed as it +// is written, then Keep puts it in place or Discard drops it. +type Incoming struct { + f *os.File + h hash.Hash + Bytes int64 +} + +// Receive copies r, up to max bytes, into a new incoming file. +func (s *Files) Receive(r io.Reader, max int64) (*Incoming, error) { + dir := filepath.Join(s.Root, ".incoming") + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + f, err := os.CreateTemp(dir, "part-*") + if err != nil { + return nil, err + } + in := &Incoming{f: f, h: sha256.New()} + n, err := io.Copy(io.MultiWriter(f, in.h), io.LimitReader(r, max+1)) + in.Bytes = n + if err == nil && n > max { + err = ErrTooLarge{max} + } + if err == nil { + err = f.Sync() + } + if err != nil { + in.Discard() + return nil, err + } + return in, nil +} + +// ErrTooLarge is a part over its limit. +type ErrTooLarge struct{ Max int64 } + +func (e ErrTooLarge) Error() string { return fmt.Sprintf("larger than %d bytes", e.Max) } + +// Sum is the file's sha256. +func (in *Incoming) Sum() string { return hex.EncodeToString(in.h.Sum(nil)) } + +// Open reads the received file from its start. +func (in *Incoming) Open() (io.ReadSeeker, error) { + _, err := in.f.Seek(0, io.SeekStart) + return in.f, err +} + +// Discard removes the incoming file. +func (in *Incoming) Discard() { + if in == nil || in.f == nil { + return + } + name := in.f.Name() + in.f.Close() + os.Remove(name) + in.f = nil +} + +// Keep puts the file at its content's name, read-only. Bytes already stored +// under that name are left as they are -- equal by construction. +func (s *Files) Keep(in *Incoming) (string, error) { + sum := in.Sum() + dst := s.path(sum) + name := in.f.Name() + in.f.Close() + in.f = nil + defer os.Remove(name) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", err + } + if err := os.Chmod(name, 0o444); err != nil { + return "", err + } + // link, never rename: link refuses an existing name, so nothing stored + // is replaced, not even by the same bytes. + if err := os.Link(name, dst); err != nil && !errors.Is(err, fs.ErrExist) { + return "", err + } + return sum, nil +} + +// Put stores bytes already in memory (a redacted copy). +func (s *Files) Put(data []byte) (string, error) { + in, err := s.Receive(bytes.NewReader(data), int64(len(data))) + if err != nil { + return "", err + } + return s.Keep(in) +} + +// Has reports whether the file is stored and still has the bytes its name +// says. +func (s *Files) Has(sum string) (bool, error) { + f, err := os.Open(s.path(sum)) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return false, err + } + return hex.EncodeToString(h.Sum(nil)) == sum, nil +} + +// Remove deletes a stored file. Takedown only, and only once no row names it. +func (s *Files) Remove(sum string) error { + if !hexSum.MatchString(sum) { + return fmt.Errorf("not a sha256: %q", sum) + } + err := os.Remove(s.path(sum)) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} diff --git a/service/internal/reports/handler.go b/service/internal/reports/handler.go new file mode 100644 index 00000000..73b877cf --- /dev/null +++ b/service/internal/reports/handler.go @@ -0,0 +1,494 @@ +package reports + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "path" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/OpenIPC/website/service/internal/httpx" +) + +// Limits on one upload. +const ( + DailyPerClient = 10 // reports per client address per day + maxFiles = 24 + maxPhoto = 20 << 20 + maxText = 4 << 20 + maxDocument = 20 << 20 + maxField = 4000 +) + +// API is the reports' addresses on the web role. +type API struct { + DB *pgxpool.Pool + Files *Files + // AccelPrefix is nginx's internal location aliasing Files.Root. + AccelPrefix string + Log *slog.Logger + Now func() time.Time +} + +func (a *API) Handlers() map[string]http.Handler { + return map[string]http.Handler{ + "POST /api/v1/reports": http.HandlerFunc(a.upload), + "GET /api/v1/reports/{id}": http.HandlerFunc(a.view), + "GET /api/v1/reports/{id}/files/{position}": http.HandlerFunc(a.file), + "POST /api/v1/boards/identify": http.HandlerFunc(a.identify), + } +} + +func (a *API) store() *Store { return &Store{DB: a.DB} } + +func (a *API) now() time.Time { + if a.Now != nil { + return a.Now() + } + return time.Now() +} + +// part is one received file before it is kept. +type part struct { + kind, name, mime string + in *Incoming +} + +// received is an upload read off the wire. +type received struct { + yaml string + backup *Incoming + parts []part + fields map[string]string + discard func() +} + +var textKinds = map[string]bool{"boot_log": true, "uboot_env": true, "note": true} +var fileKinds = map[string]int64{"photo": maxPhoto, "boot_log": maxText, "uboot_env": maxText, "note": maxText, "document": maxDocument} + +// upload is POST /api/v1/reports. The body is multipart -- a yaml part (or +// field), an optional backup, any of photo, boot_log, uboot_env, note and +// document, and the fields consent, channel, tool and note -- or, for +// `ipctool | curl --data-binary @- .../api/v1/reports`, ipctool's output as +// the whole body. +func (a *API) upload(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + st := a.store() + key, err := st.Key(ctx) + if err != nil { + a.fail(w, "the report key", err) + return + } + client := Keyed(key, "client", httpx.ClientIP(r)) + n, err := st.UploadsSince(ctx, client, a.now().Add(-24*time.Hour)) + if err != nil { + a.fail(w, "the daily count", err) + return + } + if n >= DailyPerClient { + w.Header().Set("Retry-After", "3600") + a.refuse(w, http.StatusTooManyRequests, fmt.Sprintf("%d reports a day from one address is the limit; send the rest tomorrow", DailyPerClient)) + return + } + + in, status, err := a.read(r) + if in != nil { + defer in.discard() + } + if err != nil { + a.refuse(w, status, err.Error()) + return + } + channel := orDefault(in.fields["channel"], "ipctool") + if channel != "ipctool" && channel != "agent" && channel != "web" { + a.refuse(w, http.StatusBadRequest, "channel is ipctool, agent or web") + return + } + + // A backup carries the YAML it was taken with; alone, it is the report. + var backup Backup + if in.backup != nil { + f, err := in.backup.Open() + if err == nil { + backup, err = ReadBackup(f) + } + if err != nil { + a.refuse(w, http.StatusBadRequest, "backup: "+err.Error()) + return + } + if in.yaml == "" { + in.yaml = backup.YAML + } else if Clean(in.yaml) != Clean(backup.YAML) { + a.refuse(w, http.StatusBadRequest, "the backup was taken with other ipctool output than the yaml sent with it") + return + } + } + doc, facts, err := Parse(in.yaml) + if err != nil { + a.refuse(w, http.StatusBadRequest, err.Error()) + return + } + consent := "none" + if in.backup != nil { + consent = orDefault(in.fields["consent"], "private") + if consent != "private" && consent != "public" { + a.refuse(w, http.StatusBadRequest, "consent is private (OpenIPC's maintainers only) or public (published with the report)") + return + } + } + + rep := &Report{ + Channel: channel, Tool: in.fields["tool"], Note: in.fields["note"], + YAML: doc, YAMLPublic: Redact(doc, facts, key), Facts: facts, + IDHashes: facts.IDHashes(key), Consent: consent, ClientHash: client, + } + sum := sha256.Sum256([]byte(doc)) + rep.YAMLSHA256 = hex.EncodeToString(sum[:]) + + var kept []string + keep := func(p part) (File, error) { + f := File{Kind: p.kind, Name: p.name, Mime: p.mime, Bytes: p.in.Bytes} + var text []byte + if textKinds[p.kind] { + rd, err := p.in.Open() + if err != nil { + return f, err + } + if text, err = io.ReadAll(rd); err != nil { + return f, err + } + } + s, err := a.Files.Keep(p.in) + if err != nil { + return f, err + } + kept = append(kept, s) + f.SHA256 = s + switch { + case p.kind == "backup" && consent != "public": + // stored for the maintainers, never served + case textKinds[p.kind]: + pub := []byte(Redact(string(text), facts, key)) + ps, err := a.Files.Put(pub) + if err != nil { + return f, err + } + kept = append(kept, ps) + f.PublicSHA256, f.PublicBytes = ps, int64(len(pub)) + default: + f.PublicSHA256, f.PublicBytes = s, f.Bytes + } + return f, nil + } + all := in.parts + if in.backup != nil { + all = append([]part{{kind: "backup", name: "backup.bin", mime: "application/octet-stream", in: in.backup}}, all...) + } + for _, p := range all { + f, err := keep(p) + if err != nil { + a.fail(w, "a file", err) + return + } + rep.Files = append(rep.Files, f) + } + if err := st.Insert(ctx, rep); err != nil { + // The files already kept stay: another upload may have kept the + // same bytes a moment ago and be about to name them. A file no row + // names is harmless, and `openipc reports verify` counts them. + a.Log.Warn("reports: files kept for a report that was not stored", "sums", kept) + a.fail(w, "the report", err) + return + } + ident, err := Identify(ctx, a.DB, facts) + if err != nil { + a.Log.Warn("reports: identify failed", "report", rep.ID, "err", err) + } + a.Log.Info("reports: received", "report", rep.ID, "channel", channel, "chip", facts.ChipModel, + "files", len(rep.Files), "consent", consent, "known", ident.Known) + + type receivedFile struct { + Kind string `json:"kind"` + Name string `json:"name"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` + Private bool `json:"private,omitempty"` + } + files := []receivedFile{} + for _, f := range rep.Files { + files = append(files, receivedFile{f.Kind, f.Name, f.Bytes, f.SHA256, f.PublicSHA256 == ""}) + } + out := map[string]any{ + "id": rep.ID, "status": "pending", "receipt_url": receiptURL(r, rep.ID), + "received_at": rep.ReceivedAt, "backup_consent": consent, + "facts": facts, "files": files, "identify": ident, + "next": "OpenIPC's maintainers review each report before it is published. Nothing identifying the camera " + + "(MAC, die ID, cloud ID) is ever shown; the receipt shows the report's state.", + } + if in.backup != nil { + out["backup"] = map[string]any{"partitions": len(backup.Blocks), "flash_bytes": backup.Size()} + } + writeJSON(w, http.StatusCreated, out) +} + +func receiptURL(r *http.Request, id string) string { + host := r.Host + if host == "" { + host = "openipc.org" + } + return "https://" + host + "/cameras/report/?id=" + id +} + +var safeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +func fileName(given, kind string, i int) string { + n := safeName.ReplaceAllString(path.Base(strings.ReplaceAll(given, `\`, "/")), "_") + n = strings.Trim(n, "._-") + if len(n) > 120 { + n = n[len(n)-120:] + } + if n == "" || n == "." { + n = kind + "-" + strconv.Itoa(i) + } + return n +} + +// read takes the body apart, streaming every file to .incoming/ as it +// arrives, so a 128 MB backup never sits in memory. +func (a *API) read(r *http.Request) (*received, int, error) { + in := &received{fields: map[string]string{}} + in.discard = func() { + in.backup.Discard() + for _, p := range in.parts { + p.in.Discard() + } + } + ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + if ct != "multipart/form-data" { + body, err := io.ReadAll(io.LimitReader(r.Body, MaxYAML+1)) + if err != nil { + return in, http.StatusBadRequest, errors.New("the body could not be read") + } + if len(body) > MaxYAML { + return in, http.StatusRequestEntityTooLarge, fmt.Errorf("ipctool's output is larger than %d KB", MaxYAML>>10) + } + in.yaml = string(body) + for _, k := range []string{"channel", "tool", "note"} { + in.fields[k] = r.URL.Query().Get(k) + } + return in, 0, nil + } + mr, err := r.MultipartReader() + if err != nil { + return in, http.StatusBadRequest, errors.New("the multipart body could not be read") + } + for { + p, err := mr.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return in, http.StatusBadRequest, errors.New("the multipart body is cut short") + } + name := p.FormName() + switch { + case name == "yaml": + b, err := io.ReadAll(io.LimitReader(p, MaxYAML+1)) + if err != nil || len(b) > MaxYAML { + return in, http.StatusRequestEntityTooLarge, fmt.Errorf("yaml: larger than %d KB", MaxYAML>>10) + } + in.yaml = string(b) + case name == "backup": + if in.backup != nil { + return in, http.StatusBadRequest, errors.New("one backup per report") + } + inc, err := a.Files.Receive(p, MaxBackup) + if err != nil { + return in, sizeStatus(err), fmt.Errorf("backup: %v", err) + } + in.backup = inc + case fileKinds[name] > 0: + if len(in.parts) == maxFiles { + return in, http.StatusBadRequest, fmt.Errorf("at most %d files per report", maxFiles) + } + inc, err := a.Files.Receive(p, fileKinds[name]) + if err != nil { + return in, sizeStatus(err), fmt.Errorf("%s: %v", name, err) + } + pt := part{kind: name, name: fileName(p.FileName(), name, len(in.parts)+1), in: inc} + in.parts = append(in.parts, pt) + mt, err := sniff(inc, name) + if err != nil { + return in, http.StatusUnsupportedMediaType, fmt.Errorf("%s %s: %v", name, pt.name, err) + } + in.parts[len(in.parts)-1].mime = mt + case name == "consent" || name == "channel" || name == "tool" || name == "note": + b, err := io.ReadAll(io.LimitReader(p, maxField+1)) + if err != nil || len(b) > maxField || !utf8.Valid(b) { + return in, http.StatusBadRequest, fmt.Errorf("%s: at most %d characters of text", name, maxField) + } + in.fields[name] = strings.TrimSpace(string(b)) + default: + return in, http.StatusBadRequest, fmt.Errorf("%q is not a part a report has: yaml, backup, photo, boot_log, uboot_env, note, document, consent, channel, tool", name) + } + } + if in.yaml == "" && in.backup == nil { + return in, http.StatusBadRequest, errors.New("a report needs ipctool's output: a yaml part, or a backup") + } + return in, 0, nil +} + +func sizeStatus(err error) int { + var big ErrTooLarge + if errors.As(err, &big) { + return http.StatusRequestEntityTooLarge + } + return http.StatusBadRequest +} + +// sniff decides a file's type from its bytes, never from what the client +// declared: photos are JPEG, PNG or WebP; text is text; a document is a PDF. +func sniff(in *Incoming, kind string) (string, error) { + rd, err := in.Open() + if err != nil { + return "", err + } + head := make([]byte, 512) + n, _ := io.ReadFull(rd, head) + head = head[:n] + got := http.DetectContentType(head) + switch kind { + case "photo": + switch got { + case "image/jpeg", "image/png", "image/webp": + return got, nil + } + return "", errors.New("a photo is JPEG, PNG or WebP") + case "document": + if got == "application/pdf" { + return got, nil + } + return "", errors.New("a document is a PDF") + default: + if strings.HasPrefix(got, "text/plain") || got == "application/octet-stream" && printable(head) { + return "text/plain; charset=utf-8", nil + } + return "", errors.New("a console capture is text") + } +} + +// printable: a console capture with a stray control byte is still text. +func printable(b []byte) bool { + bad := 0 + for _, c := range b { + if c < 0x09 || (c > 0x0d && c < 0x20 && c != 0x1b) { + bad++ + } + } + return bad*20 < len(b)+1 +} + +// view is GET /api/v1/reports/{id}: the receipt, and once published the +// report. +func (a *API) view(w http.ResponseWriter, r *http.Request) { + v, err := a.store().Public(r.Context(), r.PathValue("id")) + if errors.Is(err, ErrNotFound) { + a.refuse(w, http.StatusNotFound, "no report has this id") + return + } + if err != nil { + a.fail(w, "the report", err) + return + } + writeJSON(w, http.StatusOK, v) +} + +// file is GET /api/v1/reports/{id}/files/{position}: a published report's +// file, handed to nginx to send. A private backup, or any file of an +// unpublished report, is a 404 -- the same as one that does not exist. +func (a *API) file(w http.ResponseWriter, r *http.Request) { + pos, err := strconv.Atoi(r.PathValue("position")) + if err != nil || pos < 1 { + http.NotFound(w, r) + return + } + sum, name, mt, kind, err := a.store().Served(r.Context(), r.PathValue("id"), pos) + if errors.Is(err, ErrNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + a.fail(w, "the file", err) + return + } + disposition := "inline" + if kind == "backup" || kind == "document" { + disposition = "attachment" + } + w.Header().Set("Content-Type", mt) + w.Header().Set("Content-Disposition", disposition+`; filename="`+r.PathValue("id")+"-"+name+`"`) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Header().Set("X-Accel-Redirect", strings.TrimSuffix(a.AccelPrefix, "/")+"/"+Rel(sum)) + w.WriteHeader(http.StatusOK) +} + +// identify is POST /api/v1/boards/identify: which catalogue boards +// ipctool's output may be from, before anything is uploaded. Nothing is +// stored. +func (a *API) identify(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, MaxYAML+1)) + if err != nil || len(body) > MaxYAML { + a.refuse(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("send ipctool's output, at most %d KB", MaxYAML>>10)) + return + } + _, facts, err := Parse(string(body)) + if err != nil { + a.refuse(w, http.StatusBadRequest, err.Error()) + return + } + id, err := Identify(r.Context(), a.DB, facts) + if err != nil { + a.fail(w, "identify", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"facts": facts, "identify": id}) +} + +func (a *API) refuse(w http.ResponseWriter, status int, reason string) { + a.Log.Info("reports: refused", "status", status, "reason", reason) + writeJSON(w, status, map[string]string{"error": reason}) +} + +func (a *API) fail(w http.ResponseWriter, what string, err error) { + a.Log.Error("reports: "+what+" failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "the report could not be stored; retry"}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func orDefault(v, d string) string { + if v == "" { + return d + } + return v +} diff --git a/service/internal/reports/identify.go b/service/internal/reports/identify.go new file mode 100644 index 00000000..38c8b863 --- /dev/null +++ b/service/internal/reports/identify.go @@ -0,0 +1,161 @@ +package reports + +import ( + "context" + "regexp" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/OpenIPC/website/service/internal/boards" +) + +// Match is a catalogue board a report may be from, and why. +type Match struct { + ModelID string `json:"model_id"` + Model string `json:"model"` + Manufacturer string `json:"manufacturer"` + SoC string `json:"soc"` + URL string `json:"url"` + Score int `json:"score"` + Why []string `json:"why"` +} + +// Identification is what POST /api/v1/boards/identify answers, and what an +// upload's answer carries: the SoC the catalogue would file the board under, +// the boards it may be, and whether one of them is certain (its board code +// is one the catalogue already knows). +type Identification struct { + SoC string `json:"soc"` + Known bool `json:"known"` + Matches []Match `json:"matches"` +} + +// SoCID is the catalogue's name for the chip ipctool reports: HiSilicon's +// "3516CV300" is hi3516cv300, Goke's "7205V200" gk7205v200, the rest are +// their model in lower case. +func SoCID(vendor, model string) string { + m := strings.ToLower(strings.TrimSpace(model)) + if m == "" { + return "" + } + if m[0] >= '0' && m[0] <= '9' { + switch strings.ToLower(strings.TrimSpace(vendor)) { + case "hisilicon": + return "hi" + m + case "goke": + return "gk" + m + } + } + return m +} + +// ingenicVariant: T31L, T31N, T31X, T31ZX are all the catalogue's t31. +var ingenicVariant = regexp.MustCompile(`^(t\d+)[a-z]+$`) + +func socCandidates(soc string) []string { + out := []string{soc} + if m := ingenicVariant.FindStringSubmatch(soc); m != nil { + out = append(out, m[1]) + } + return out +} + +// Identify matches a report's facts against the board catalogue. It reads +// the board tables and never writes them. +func Identify(ctx context.Context, db *pgxpool.Pool, f Facts) (Identification, error) { + id := Identification{SoC: SoCID(f.ChipVendor, f.ChipModel), Matches: []Match{}} + found := map[string]*Match{} + add := func(m Match, score int, why string) { + if cur, ok := found[m.ModelID]; ok { + cur.Score += score + cur.Why = append(cur.Why, why) + return + } + m.Score, m.Why = score, []string{why} + found[m.ModelID] = &m + } + scan := func(sql string, score int, why string, args ...any) error { + rows, err := db.Query(ctx, sql, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var m Match + if err := rows.Scan(&m.ModelID, &m.Model, &m.Manufacturer, &m.SoC); err != nil { + return err + } + add(m, score, why) + } + return rows.Err() + } + const cols = `SELECT m.id, m.model, mf.name, coalesce(m.soc, '') FROM board_models m + JOIN board_manufacturers mf ON mf.id = m.manufacturer_id ` + + // The board code a vendor prints (Xiongmai's 50H20L, a module's name) is + // the one certain match. + if code := boards.NormCode(f.BoardModel); code != "" && len(code) >= 4 { + if err := scan(cols+`WHERE m.id IN (SELECT model_id FROM board_model_aliases WHERE code_norm = $1) + OR upper(regexp_replace(m.model, '[^A-Za-z0-9]+', '-', 'g')) = $1`, + 100, "board code "+f.BoardModel, code); err != nil { + return id, err + } + } + if id.SoC != "" { + socs := socCandidates(id.SoC) + if err := scan(cols+`WHERE lower(m.soc) = ANY($1)`, 10, "SoC "+id.SoC, socs); err != nil { + return id, err + } + if sensor := sensorModel(f.Sensor); sensor != "" { + if err := scan(cols+`WHERE lower(m.soc) = ANY($1) AND EXISTS ( + SELECT 1 FROM board_units u WHERE u.model_id = m.id AND u.sensor ILIKE '%' || $2 || '%')`, + 20, "sensor "+sensor, socs, sensor); err != nil { + return id, err + } + } + if f.FlashName != "" { + if err := scan(cols+`WHERE lower(m.soc) = ANY($1) AND EXISTS ( + SELECT 1 FROM board_units u WHERE u.model_id = m.id AND u.flash_chip ILIKE '%' || $2 || '%')`, + 5, "flash "+f.FlashName, socs, f.FlashName); err != nil { + return id, err + } + } + } + for _, m := range found { + // A board that shares only the SoC with the report is one of + // hundreds; it is not a match on its own. + if m.Score <= 10 { + continue + } + m.URL = "/cameras/boards?model=" + m.ModelID + if m.Score >= 100 { + id.Known = true + } + id.Matches = append(id.Matches, *m) + } + sort.Slice(id.Matches, func(i, j int) bool { + if id.Matches[i].Score != id.Matches[j].Score { + return id.Matches[i].Score > id.Matches[j].Score + } + return id.Matches[i].ModelID < id.Matches[j].ModelID + }) + if len(id.Matches) > 10 { + id.Matches = id.Matches[:10] + } + return id, nil +} + +// sensorModel is the part number without the vendor: "Sony IMX291" is IMX291. +func sensorModel(s string) string { + f := strings.Fields(s) + if len(f) == 0 { + return "" + } + m := f[len(f)-1] + if len(m) < 4 { + return "" + } + return m +} diff --git a/service/internal/reports/parse.go b/service/internal/reports/parse.go new file mode 100644 index 00000000..dce9d612 --- /dev/null +++ b/service/internal/reports/parse.go @@ -0,0 +1,242 @@ +package reports + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + + "go.yaml.in/yaml/v3" +) + +// MaxYAML bounds ipctool's output. A real one is 2-6 KB; the sensor's LVDS +// sync codes are the longest part. +const MaxYAML = 256 << 10 + +// Facts is what a report's YAML says about the board, for matching it to the +// catalogue and listing it. +type Facts struct { + ChipVendor string `json:"chip_vendor,omitempty"` + ChipModel string `json:"chip_model,omitempty"` + Sensor string `json:"sensor,omitempty"` + FlashID string `json:"flash_id,omitempty"` + FlashName string `json:"flash_name,omitempty"` + FlashSize string `json:"flash_size,omitempty"` + BoardVendor string `json:"board_vendor,omitempty"` + BoardModel string `json:"board_model,omitempty"` + MainApp string `json:"main_app,omitempty"` + // Partition hashes as ipctool prints them (the first 8 hex digits of a + // partition's sha1), by name; equal hashes mean identical partitions. + Partitions map[string]string `json:"partitions,omitempty"` + + // The board's identifiers. Never stored as they are and never served: + // Identify keys them (IDHashes) and Redact replaces them. + MAC string `json:"-"` + DieID string `json:"-"` + CloudID string `json:"-"` +} + +// Parse reads ipctool's output: the YAML as printed, or with whatever a +// shell printed around it (extutils' "installed as remote GitHub plugin", +// a prompt, a trailing newline). It needs a chip section; everything else +// is optional, because ipctool drops a section it could not fill. +func Parse(raw string) (string, Facts, error) { + doc := Clean(raw) + var f Facts + if doc == "" { + return "", f, errors.New("the report has no ipctool output: it needs at least the chip: section") + } + if len(doc) > MaxYAML { + return "", f, fmt.Errorf("ipctool's output is larger than %d KB", MaxYAML>>10) + } + var y struct { + Chip struct { + Vendor string `yaml:"vendor"` + Model string `yaml:"model"` + ID string `yaml:"id"` + } `yaml:"chip"` + Board map[string]any `yaml:"board"` + Ethernet struct { + MAC string `yaml:"mac"` + } `yaml:"ethernet"` + ROM []struct { + Type string `yaml:"type"` + Size string `yaml:"size"` + Chip struct { + Name string `yaml:"name"` + ID string `yaml:"id"` + } `yaml:"chip"` + Partitions []struct { + Name string `yaml:"name"` + SHA1 string `yaml:"sha1"` + } `yaml:"partitions"` + } `yaml:"rom"` + Firmware map[string]any `yaml:"firmware"` + Sensors []struct { + Vendor string `yaml:"vendor"` + Model string `yaml:"model"` + } `yaml:"sensors"` + } + if err := yaml.Unmarshal([]byte(doc), &y); err != nil { + return "", f, fmt.Errorf("ipctool's output does not read as YAML: %v", err) + } + if y.Chip.Model == "" && y.Chip.Vendor == "" { + return "", f, errors.New("the report has no chip: section; send ipctool's whole output") + } + f.ChipVendor, f.ChipModel, f.DieID = y.Chip.Vendor, y.Chip.Model, y.Chip.ID + f.MAC = y.Ethernet.MAC + f.BoardVendor = str(y.Board["vendor"]) + for _, k := range []string{"model", "param"} { + if v := str(y.Board[k]); v != "" { + f.BoardModel = v + break + } + } + for _, k := range []string{"cloudId", "chip-id"} { + if v := str(y.Board[k]); v != "" { + f.CloudID = v + break + } + } + f.MainApp = str(y.Firmware["main-app"]) + if len(y.Sensors) > 0 { + f.Sensor = strings.TrimSpace(y.Sensors[0].Vendor + " " + y.Sensors[0].Model) + } + if len(y.ROM) > 0 { + r := y.ROM[0] + f.FlashID, f.FlashName, f.FlashSize = r.Chip.ID, r.Chip.Name, r.Size + for _, p := range r.Partitions { + if p.SHA1 != "" { + if f.Partitions == nil { + f.Partitions = map[string]string{} + } + f.Partitions[p.Name] = p.SHA1 + } + } + } + return doc, f, nil +} + +func str(v any) string { + if v == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(v)) +} + +var topKey = regexp.MustCompile(`^[a-z][a-z0-9_-]*:`) + +// Clean cuts ipctool's document out of what a terminal captured: from the +// first top-level key (after a "---" if there is one) to the end, with +// carriage returns and trailing blanks gone. "" when there is no document. +func Clean(raw string) string { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.TrimPrefix(raw, "\ufeff") + lines := strings.Split(raw, "\n") + start := -1 + for i, l := range lines { + if strings.TrimRight(l, " \t") == "---" { + start = i + 1 + break + } + } + if start < 0 { + start = 0 + } + for start < len(lines) && !topKey.MatchString(lines[start]) { + start++ + } + if start >= len(lines) { + return "" + } + // The document ends where a line is neither indented, a list item nor a + // top-level key: the shell's prompt after ipctool exited. + end := start + 1 + for end < len(lines) { + l := lines[end] + if l != "" && l[0] != ' ' && l[0] != '-' && l[0] != '\t' && !topKey.MatchString(l) { + break + } + end++ + } + doc := strings.Join(lines[start:end], "\n") + doc = strings.TrimRight(doc, " \t\n\x00") + if !strings.Contains("\n"+doc, "\nchip:") { + return "" + } + return doc + "\n" +} + +// Identifier is one of the values that single out one physical board. +type Identifier struct { + Name string // mac, die_id, cloud_id + Value string +} + +// Identifiers lists the board's identifiers, as found. +func (f Facts) Identifiers() []Identifier { + var out []Identifier + for _, id := range []Identifier{{"mac", f.MAC}, {"die_id", f.DieID}, {"cloud_id", f.CloudID}} { + // Shorter than six characters is not an identifier, and replacing it + // everywhere would take the document apart with it. + if v := strings.TrimSpace(id.Value); len(v) >= 6 && !zeroish(v) { + out = append(out, Identifier{id.Name, v}) + } + } + return out +} + +// zeroish: an all-zero or all-F value identifies nothing. +func zeroish(v string) bool { + s := strings.ToLower(strings.NewReplacer(":", "", "-", "", "0x", "").Replace(v)) + return strings.Trim(s, "0") == "" || strings.Trim(s, "f") == "" +} + +// Keyed hashes an identifier with the database's report key: the same board +// gives the same value in every report, and the value gives nothing back. +func Keyed(key, name, value string) string { + m := hmac.New(sha256.New, []byte(key)) + m.Write([]byte(name + "\x00" + strings.ToLower(strings.TrimSpace(value)))) + return hex.EncodeToString(m.Sum(nil))[:16] +} + +// IDHashes is id_hashes: each identifier, keyed. +func (f Facts) IDHashes(key string) map[string]string { + out := map[string]string{} + for _, id := range f.Identifiers() { + out[id.Name] = Keyed(key, id.Name, id.Value) + } + return out +} + +// Redact replaces every spelling of the board's identifiers in text -- +// ipctool's YAML, a boot log, a U-Boot environment -- with a placeholder +// naming its keyed hash, so the public copy still shows that two reports +// come from one board. A MAC is found with any separator or none, in either +// case. +func Redact(text string, f Facts, key string) string { + for _, id := range f.Identifiers() { + mark := "<" + id.Name + ":" + Keyed(key, id.Name, id.Value) + ">" + text = spellings(id).ReplaceAllString(text, mark) + } + return text +} + +func spellings(id Identifier) *regexp.Regexp { + v := strings.TrimSpace(id.Value) + if id.Name == "mac" { + hexd := strings.NewReplacer(":", "", "-", "", ".", "").Replace(v) + if len(hexd) == 12 { + var parts []string + for i := 0; i < 12; i += 2 { + parts = append(parts, regexp.QuoteMeta(hexd[i:i+2])) + } + return regexp.MustCompile(`(?i)` + strings.Join(parts, `[:-]?`)) + } + } + v = strings.TrimPrefix(strings.TrimPrefix(v, "0x"), "0X") + return regexp.MustCompile(`(?i)(?:0x)?` + regexp.QuoteMeta(v)) +} diff --git a/service/internal/reports/parse_test.go b/service/internal/reports/parse_test.go new file mode 100644 index 00000000..4e31cc7d --- /dev/null +++ b/service/internal/reports/parse_test.go @@ -0,0 +1,156 @@ +package reports + +import ( + "bytes" + "encoding/binary" + "os" + "strings" + "testing" +) + +func fixture(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// What the lab cameras printed, with the banner extutils prints in front. +func TestRealOutputIsReadWithWhateverTheShellPrintedAroundIt(t *testing.T) { + cases := []struct { + file string + chip, sensor, flash, mac string + }{ + {"hi3516cv300-imx291.txt", "3516CV300", "Sony IMX291", "16M", "00:12:31:5e:e0:d2"}, + {"t31-sc2332.txt", "T31L", "", "", "38:01:46:a2:e6:38"}, + {"hi3516ev300-imx335.txt", "3516EV300", "Sony IMX335", "", "02:8f:5c:94:d7:e7"}, + {"xiongmai-50h20l-readme.yml", "3516CV300", "Sony IMX291", "8M", "00:12:89:12:88:e1"}, + } + for _, c := range cases { + raw := fixture(t, c.file) + doc, f, err := Parse("root@camera:~# ipctool\r\n" + strings.ReplaceAll(raw, "\n", "\r\n") + "root@camera:~# ") + if err != nil { + t.Fatalf("%s: %v", c.file, err) + } + if !strings.HasPrefix(doc, "chip:") && !strings.HasPrefix(doc, "board:") { + t.Errorf("%s: the document starts %q", c.file, doc[:20]) + } + if strings.Contains(doc, "\r") || strings.Contains(doc, "GitHub plugin") || strings.Contains(doc, "root@camera") { + t.Errorf("%s: the shell's text survived", c.file) + } + if f.ChipModel != c.chip || f.MAC != c.mac { + t.Errorf("%s: chip %q mac %q", c.file, f.ChipModel, f.MAC) + } + if c.sensor != "" && f.Sensor != c.sensor { + t.Errorf("%s: sensor %q, want %q", c.file, f.Sensor, c.sensor) + } + if c.flash != "" && f.FlashSize != c.flash { + t.Errorf("%s: flash %q, want %q", c.file, f.FlashSize, c.flash) + } + } + _, f, _ := Parse(fixture(t, "xiongmai-50h20l-readme.yml")) + if f.BoardVendor != "Xiongmai" || f.BoardModel != "50H20L" || f.CloudID != "3beae2b40d84f889" || f.FlashID != "0xef4018" { + t.Errorf("the Xiongmai board: %+v", f) + } +} + +func TestSomethingThatIsNotIpctoolOutputIsRefused(t *testing.T) { + for _, raw := range []string{"", "hello", "board:\n vendor: x\n", "chip: [unclosed\n"} { + if _, _, err := Parse(raw); err == nil { + t.Errorf("%q was accepted", raw) + } + } +} + +// The public copy keeps everything that identifies the board's kind and +// nothing that identifies the board: MAC, die ID and cloud ID, in any +// spelling, anywhere in the text. +func TestThePublicCopyHasNoIdentifierInAnySpelling(t *testing.T) { + const key = "k" + doc, f, err := Parse(fixture(t, "hi3516ev300-imx335.txt")) + if err != nil { + t.Fatal(err) + } + pub := Redact(doc, f, key) + if _, f2, err := Parse(pub); err != nil || f2.ChipModel != "3516EV300" || f2.Sensor != "Sony IMX335" { + t.Fatalf("the redacted YAML no longer reads: %v %+v", err, f2) + } + for _, gone := range []string{"02:8f:5c:94:d7:e7", "02143906de0038e9c170030a8771e5942649f51410cf29e3"} { + if strings.Contains(strings.ToLower(pub), gone) { + t.Errorf("%s is in the public copy", gone) + } + } + if !strings.Contains(pub, "") { + t.Error("the MAC's placeholder is missing") + } + log := "ethaddr=02:8F:5C:94:D7:E7\nMAC 02-8f-5c-94-d7-e7, raw 028f5c94d7e7\nchip id 0x02143906DE0038E9C170030A8771E5942649F51410CF29E3\n" + got := Redact(log, f, key) + if strings.Contains(strings.ToLower(got), "8f5c94") || strings.Contains(strings.ToLower(got), "0038e9c1") { + t.Errorf("an identifier survived:\n%s", got) + } + _, x, _ := Parse(fixture(t, "xiongmai-50h20l-readme.yml")) + if xp := Redact(fixture(t, "xiongmai-50h20l-readme.yml"), x, key); strings.Contains(xp, "3beae2b40d84f889") { + t.Error("the Xiongmai cloud ID survived") + } +} + +func TestTheSameBoardHashesTheSameAndAZeroMACIsNoIdentifier(t *testing.T) { + a := Facts{MAC: "02:8F:5C:94:D7:E7"} + b := Facts{MAC: "02:8f:5c:94:d7:e7"} + if a.IDHashes("k")["mac"] != b.IDHashes("k")["mac"] || a.IDHashes("k")["mac"] == a.IDHashes("other")["mac"] { + t.Error("keyed hashes do not follow the board and the key") + } + if ids := (Facts{MAC: "00:00:00:00:00:00", DieID: "0x0", CloudID: "ffffffff"}).Identifiers(); len(ids) != 0 { + t.Errorf("placeholders counted as identifiers: %v", ids) + } +} + +func backupOf(yaml string, parts ...[]byte) []byte { + var b bytes.Buffer + b.WriteString(yaml) + b.WriteByte(0) + for _, p := range parts { + _ = binary.Write(&b, binary.LittleEndian, uint32(len(p))) + b.Write(p) + } + return b.Bytes() +} + +func TestABackupIsReadToItsLastByte(t *testing.T) { + yaml := fixture(t, "hi3516cv300-imx291.txt") + whole := backupOf(yaml, bytes.Repeat([]byte{1}, 4096), bytes.Repeat([]byte{2}, 100)) + b, err := ReadBackup(bytes.NewReader(whole)) + if err != nil { + t.Fatal(err) + } + if b.YAML != yaml || len(b.Blocks) != 2 || b.Size() != 4196 { + t.Errorf("read %d blocks, %d bytes", len(b.Blocks), b.Size()) + } + for name, bad := range map[string][]byte{ + "cut short": whole[:len(whole)-1], + "no flash": backupOf(yaml), + "no NUL": []byte(yaml), + "empty block": backupOf(yaml, []byte{}), + "stray bytes": append(append([]byte{}, whole...), 1, 2), + } { + if _, err := ReadBackup(bytes.NewReader(bad)); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func TestTheCatalogueNamesTheChipAsIpctoolDoesNot(t *testing.T) { + for in, want := range map[[2]string]string{ + {"HiSilicon", "3516CV300"}: "hi3516cv300", {"Goke", "7205V200"}: "gk7205v200", + {"Ingenic", "T31L"}: "t31l", {"SigmaStar", "SSC335"}: "ssc335", + } { + if got := SoCID(in[0], in[1]); got != want { + t.Errorf("%v: %q, want %q", in, got, want) + } + } + if c := socCandidates("t31l"); len(c) != 2 || c[1] != "t31" { + t.Errorf("t31l candidates %v", c) + } +} diff --git a/service/internal/reports/store.go b/service/internal/reports/store.go new file mode 100644 index 00000000..b2b5c582 --- /dev/null +++ b/service/internal/reports/store.go @@ -0,0 +1,296 @@ +package reports + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store is the reports' rows. It is the only code that writes them; the +// migration's triggers refuse any change it does not make through Unguarded. +type Store struct { + DB *pgxpool.Pool +} + +// Report is one upload, as stored. +type Report struct { + ID string + ReceivedAt time.Time + Channel string + Tool string + Note string + YAML string + YAMLPublic string + YAMLSHA256 string + Facts Facts + IDHashes map[string]string + Consent string + ClientHash string + Files []File +} + +// File is one file a report brought. +type File struct { + Position int `json:"-"` + Kind string `json:"kind"` + Name string `json:"name"` + Mime string `json:"mime"` + SHA256 string `json:"-"` + Bytes int64 `json:"bytes"` + PublicSHA256 string `json:"sha256,omitempty"` + PublicBytes int64 `json:"-"` +} + +// Key is the database's report key (migration 016). +func (s *Store) Key(ctx context.Context) (string, error) { + var k string + err := s.DB.QueryRow(ctx, `SELECT key FROM report_key`).Scan(&k) + return k, err +} + +// NewID is a report's public id: r- and eight characters, unguessable, so a +// receipt shows only its own report. +func NewID() string { + const alphabet = "abcdefghijkmnpqrstuvwxyz23456789" + var b [8]byte + _, _ = rand.Read(b[:]) + out := []byte("r-") + for _, c := range b { + out = append(out, alphabet[int(c)%len(alphabet)]) + } + return string(out) +} + +// UploadsSince counts a client's reports in the window, for the daily limit. +func (s *Store) UploadsSince(ctx context.Context, client string, since time.Time) (int, error) { + var n int + err := s.DB.QueryRow(ctx, `SELECT count(*) FROM reports WHERE client_hash = $1 AND received_at >= $2`, + client, since).Scan(&n) + return n, err +} + +// Insert stores a report and its files' rows in one transaction; the files +// themselves are already in place. The id is retried on a collision. +func (s *Store) Insert(ctx context.Context, r *Report) error { + hashes, err := json.Marshal(r.IDHashes) + if err != nil { + return err + } + for attempt := 0; ; attempt++ { + r.ID = NewID() + err = pgx.BeginFunc(ctx, s.DB, func(tx pgx.Tx) error { + f := r.Facts + if err := tx.QueryRow(ctx, ` + INSERT INTO reports (id, channel, tool, note, yaml, yaml_public, yaml_sha256, + chip_vendor, chip_model, sensor, flash_id, flash_size, board_vendor, board_model, main_app, + id_hashes, backup_consent, client_hash) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) + RETURNING received_at`, + r.ID, r.Channel, r.Tool, r.Note, r.YAML, r.YAMLPublic, r.YAMLSHA256, + f.ChipVendor, f.ChipModel, f.Sensor, f.FlashID, f.FlashSize, f.BoardVendor, f.BoardModel, f.MainApp, + hashes, r.Consent, r.ClientHash).Scan(&r.ReceivedAt); err != nil { + return err + } + for i, file := range r.Files { + var pub *string + var pubBytes *int64 + if file.PublicSHA256 != "" { + pub, pubBytes = &r.Files[i].PublicSHA256, &r.Files[i].PublicBytes + } + if _, err := tx.Exec(ctx, ` + INSERT INTO report_files (report_id, position, kind, name, mime, sha256, bytes, public_sha256, public_bytes) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + r.ID, i+1, file.Kind, file.Name, file.Mime, file.SHA256, file.Bytes, pub, pubBytes); err != nil { + return err + } + r.Files[i].Position = i + 1 + } + return nil + }) + var pg *pgconn.PgError + if err == nil || !errors.As(err, &pg) || pg.Code != "23505" || attempt >= 3 { + return err + } + } +} + +// Status is a report's newest review: pending, published, rejected or +// withdrawn. +type Status struct { + State string `json:"status"` + At *time.Time `json:"reviewed_at,omitempty"` +} + +var states = map[string]string{"publish": "published", "reject": "rejected", "withdraw": "withdrawn"} + +func (s *Store) status(ctx context.Context, q querier, id string) (Status, error) { + var decision string + var at time.Time + err := q.QueryRow(ctx, `SELECT decision, at FROM report_reviews WHERE report_id = $1 ORDER BY id DESC LIMIT 1`, + id).Scan(&decision, &at) + if errors.Is(err, pgx.ErrNoRows) { + return Status{State: "pending"}, nil + } + if err != nil { + return Status{}, err + } + return Status{State: states[decision], At: &at}, nil +} + +type querier interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// ErrNotFound is an id no report has. +var ErrNotFound = errors.New("no such report") + +// Review records a decision. Publishing and rejecting are rows added, never +// a row changed: the history is the state. +func (s *Store) Review(ctx context.Context, id, decision, by, note string) error { + if _, ok := states[decision]; !ok || decision == "withdraw" { + return fmt.Errorf("a review publishes or rejects; withdrawing is takedown") + } + if err := s.exists(ctx, id); err != nil { + return err + } + _, err := s.DB.Exec(ctx, `INSERT INTO report_reviews (report_id, decision, by, note) VALUES ($1,$2,$3,$4)`, + id, decision, by, note) + return err +} + +func (s *Store) exists(ctx context.Context, id string) error { + var one int + err := s.DB.QueryRow(ctx, `SELECT 1 FROM reports WHERE id = $1`, id).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err +} + +// Link says which catalogue board a report is from. A report may be linked +// to several (a module sold on more than one board) and relinked; relinking +// is the one change to report_models the guard lets through. +func (s *Store) Link(ctx context.Context, id, model, by string) error { + if err := s.exists(ctx, id); err != nil { + return err + } + _, err := s.DB.Exec(ctx, `INSERT INTO report_models (report_id, model_id, by) VALUES ($1,$2,$3) + ON CONFLICT DO NOTHING`, id, model, by) + return err +} + +// Unlink removes a link made in error. +func (s *Store) Unlink(ctx context.Context, id, model string) error { + return Unguarded(ctx, s.DB, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, `DELETE FROM report_models WHERE report_id = $1 AND model_id = $2`, id, model) + return err + }) +} + +// Unguarded runs fn with the reports' triggers stood down, for this +// transaction only (SET LOCAL). Takedown and Unlink are its only callers. +func Unguarded(ctx context.Context, db *pgxpool.Pool, fn func(pgx.Tx) error) error { + return pgx.BeginFunc(ctx, db, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `SET LOCAL openipc.reports_guard = 'off'`); err != nil { + return err + } + return fn(tx) + }) +} + +// Takedown withdraws a report for good -- its owner asked, or the law does. +// The row stays, so its receipt says it was withdrawn; the YAML and the +// board's identifiers are blanked, its files' rows go, and the returned +// sums are the files no other report holds, for the caller to delete. +func (s *Store) Takedown(ctx context.Context, id, by, note string) ([]string, error) { + if err := s.exists(ctx, id); err != nil { + return nil, err + } + var orphans []string + err := Unguarded(ctx, s.DB, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + DELETE FROM report_files WHERE report_id = $1 RETURNING sha256, public_sha256`, id) + if err != nil { + return err + } + var sums []string + for rows.Next() { + var sum string + var pub *string + if err := rows.Scan(&sum, &pub); err != nil { + return err + } + sums = append(sums, sum) + if pub != nil && *pub != sum { + sums = append(sums, *pub) + } + } + if err := rows.Err(); err != nil { + return err + } + if _, err := tx.Exec(ctx, ` + UPDATE reports SET yaml = '', yaml_public = '', yaml_sha256 = repeat('0', 64), note = '', id_hashes = '{}', + chip_vendor = '', chip_model = '', sensor = '', flash_id = '', flash_size = '', + board_vendor = '', board_model = '', main_app = '' + WHERE id = $1`, id); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM report_models WHERE report_id = $1`, id); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO report_reviews (report_id, decision, by, note) VALUES ($1,'withdraw',$2,$3)`, + id, by, note); err != nil { + return err + } + for _, sum := range sums { + var used bool + if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM report_files WHERE sha256 = $1 OR public_sha256 = $1)`, + sum).Scan(&used); err != nil { + return err + } + if !used { + orphans = append(orphans, sum) + } + } + return nil + }) + return orphans, err +} + +// Stored is every file sum the rows name, for Verify. +func (s *Store) Stored(ctx context.Context) ([]string, error) { + rows, err := s.DB.Query(ctx, ` + SELECT sha256 FROM report_files UNION SELECT public_sha256 FROM report_files WHERE public_sha256 IS NOT NULL + ORDER BY 1`) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowTo[string]) +} + +// Verify re-reads every file the rows name and returns the ones missing or +// changed. The nightly job fails on any. +func Verify(ctx context.Context, s *Store, files *Files) (checked int, bad []string, err error) { + sums, err := s.Stored(ctx) + if err != nil { + return 0, nil, err + } + for _, sum := range sums { + ok, err := files.Has(sum) + if err != nil { + return checked, bad, err + } + checked++ + if !ok { + bad = append(bad, sum) + } + } + return checked, bad, nil +} diff --git a/service/internal/reports/testdata/hi3516cv300-imx291.txt b/service/internal/reports/testdata/hi3516cv300-imx291.txt new file mode 100644 index 00000000..6d05437a --- /dev/null +++ b/service/internal/reports/testdata/hi3516cv300-imx291.txt @@ -0,0 +1,90 @@ +The ipctool installed as remote GitHub plugin +--- +chip: + vendor: HiSilicon + model: 3516CV300 +board: + vendor: OpenIPC + version: 2.6.09.20 +ethernet: + mac: "00:12:31:5e:e0:d2" + u-mdio-phyaddr: 1 + phy-id: 0x001cc816 + d-mdio-phyaddr: 0 +rom: +- type: nor + block: 64K + partitions: + - name: boot + size: 0x40000 + sha1: b9e93d92 + - name: env + size: 0x10000 + sha1: 1c45657e + contains: + - name: uboot-env + offset: 0x0 + - name: kernel + size: 0x300000 + sha1: ba461a00 + - name: rootfs + size: 0xa00000 + path: /,squashfs + sha1: 11a33afe + - name: rootfs_data + size: 0x2b0000 + path: /overlay,jffs2,rw + size: 16M + addr-mode: 3-byte +ram: + total: 128M + media: 64M +firmware: + u-boot: "2010.06 (Nov 14 2022 - 19:27:03)" + kernel: "3.18.20 (Sun Sep 20 17:51:17 UTC 2026)" + toolchain: buildroot-gcc-13.3.0 + sdk: "Hi3516CV300_MPP_V1.0.4.0 B050 Release (Jun 3 2018, 21:42:04)" + main-app: majestic +sensors: +- vendor: Sony + model: IMX291 + control: + bus: 0 + type: i2c + addr: 0x34 + params: + bitness: 12 + databus: LVDS 4 ch + fps: 30 + data: + type: LVDS + lane-id: + - 0 + - 1 + - 2 + - 3 + lvds-wdr-en: 0 + lvds-wdr-mode: 0 + lvds-wdr-num: 0 + raw-data-type: RAW_DATA_12BIT + sync-mode: LVDS_SYNC_MODE_SAV + data-endian: LVDS_ENDIAN_BIG + sync-code-endian: LVDS_ENDIAN_BIG + sync-code: + - - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + clock: 37.125MHz diff --git a/service/internal/reports/testdata/hi3516ev300-imx335.txt b/service/internal/reports/testdata/hi3516ev300-imx335.txt new file mode 100644 index 00000000..dff2043e --- /dev/null +++ b/service/internal/reports/testdata/hi3516ev300-imx335.txt @@ -0,0 +1,70 @@ +--- +chip: + vendor: HiSilicon + model: 3516EV300 + id: 02143906de0038e9c170030a8771e5942649f51410cf29e3 +board: + vendor: OpenIPC + version: 2.6.09.22 + possible-IR-cut-GPIO: 10,11 +ethernet: + mac: "02:8f:5c:94:d7:e7" + u-mdio-phyaddr: 1 + phy-id: 0x20669903 + d-mdio-phyaddr: 0 +rom: +- type: nor + block: 64K + partitions: + - name: boot + size: 0x40000 + sha1: 3426b679 + - name: env + size: 0x10000 + sha1: b5431ca7 + contains: + - name: uboot-env + offset: 0x0 + - name: kernel + size: 0x200000 + sha1: 96768830 + - name: rootfs + size: 0x500000 + sha1: 2ea3b982 + - name: rootfs_data + size: 0x8b0000 + path: /overlay,jffs2,rw + size: 16M + addr-mode: 3-byte +ram: + total: 128M + media: 96M +firmware: + kernel: "4.9.37 (Tue Sep 22 17:53:30 UTC 2026)" + toolchain: buildroot-gcc-13.3.0 + sdk: "Hi3516EV200_MPP_V1.0.1.2 B030 Release (Oct 18 2019, 18:21:00)" + main-app: majestic +sensors: +- vendor: Sony + model: IMX335 + control: + bus: 0 + type: i2c + addr: 0x34 + data: + type: MIPI + input-data-type: DATA_TYPE_RAW_12BIT + lane-id: + - 0 + - 1 + - 2 + - 3 + image: 2592x1944 + clock: 37.125MHz +clocks: + cpu_pll: + freq_mhz: 900 + ddr: + data_rate_mbps: 1800 + hpm: + bin: mid diff --git a/service/internal/reports/testdata/t31-sc2332.txt b/service/internal/reports/testdata/t31-sc2332.txt new file mode 100644 index 00000000..563c5951 --- /dev/null +++ b/service/internal/reports/testdata/t31-sc2332.txt @@ -0,0 +1,48 @@ +The ipctool installed as remote GitHub plugin +--- +chip: + vendor: Ingenic + model: T31L +board: + vendor: OpenIPC + version: 2.6.09.26 +ethernet: + mac: "38:01:46:a2:e6:38" +rom: +- type: nor + block: 32K + partitions: + - name: boot + size: 0x40000 + sha1: 0d15c549 + - name: env + size: 0x10000 + sha1: 40d9a686 + contains: + - name: uboot-env + offset: 0x0 + - name: kernel + size: 0x200000 + sha1: a921d79b + - name: rootfs + size: 0x500000 + sha1: dbcd094a + - name: rootfs_data + size: 0x8b0000 + path: /overlay,jffs2,rw + size: 16M +ram: + total: 64M + media: 25M +firmware: + u-boot: "2013.07-g90873ad (May 01 2024 - 19:56:32)" + kernel: "3.10.14__isvp_swan_1.0__ (PREEMPT Sat Sep 26 20:35:08 UTC 2026)" + toolchain: buildroot-gcc-13.3.0 + main-app: majestic +sensors: +- vendor: SmartSens + model: SC2332 + control: + bus: 0 + type: i2c + addr: 0x60 diff --git a/service/internal/reports/testdata/xiongmai-50h20l-readme.yml b/service/internal/reports/testdata/xiongmai-50h20l-readme.yml new file mode 100644 index 00000000..386d374b --- /dev/null +++ b/service/internal/reports/testdata/xiongmai-50h20l-readme.yml @@ -0,0 +1,106 @@ +--- +board: + vendor: Xiongmai + model: 50H20L + cloudId: 3beae2b40d84f889 +chip: + vendor: HiSilicon + model: 3516CV300 +ethernet: + mac: "00:12:89:12:88:e1" + u-mdio-phyaddr: 1 + phy-id: 0x001cc816 + d-mdio-phyaddr: 0 +rom: + - type: nor + block: 64K + chip: + name: "w25q128" + id: 0xef4018 + partitions: + - name: boot + size: 0x30000 + sha1: 7a7a83e9 + contains: + - name: xmcrypto + offset: 0x1fc00 + - name: uboot-env + offset: 0x20000 + - name: romfs + size: 0x2e0000 + path: /,squashfs + sha1: 62529dab + - name: user + size: 0x300000 + path: /usr,squashfs + sha1: cbb7e9ca + - name: web + size: 0x160000 + path: /mnt/custom/data/Fonts,squashfs + sha1: 48140b3b + - name: custom + size: 0x40000 + path: /mnt/custom,cramfs + sha1: fb72a5f5 + - name: mtd + size: 0x50000 + path: /mnt/mtd,jffs2,rw + size: 8M + addr-mode: 3-byte +ram: + total: 128M + media: 72M +firmware: + u-boot: "2010.06-svn1098 (Jun 11 2018 - 13:17:42)" + kernel: "3.18.20 (Thu Jul 5 14:44:19 CST 2018)" + toolchain: gcc version 4.9.4 20150629 (prerelease) (Hisilicon_v500_20170922) + libc: uClibc 0.9.33.2 + sdk: "Hi3516CV300_MPP_V1.0.0.0 B010 Release (Jun 22 2018, 19:22:22)" + main-app: /usr/bin/Sofia +sensors: +- vendor: Sony + model: IMX291 + control: + bus: 0 + type: i2c + addr: 0x34 + params: + bitness: 12 + databus: LVDS 4 ch + fps: 30 + data: + type: LVDS + lane-id: + - 0 + - 1 + - 2 + - 3 + lvds-wdr-en: 0 + lvds-wdr-mode: 0 + lvds-wdr-num: 0 + raw-data-type: RAW_DATA_12BIT + sync-mode: LVDS_SYNC_MODE_SAV + data-endian: LVDS_ENDIAN_BIG + sync-code-endian: LVDS_ENDIAN_BIG + sync-code: + - + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + - 0xab0, 0xb60, 0x800, 0x9d0 + clock: 37.125MHz diff --git a/service/internal/reports/view.go b/service/internal/reports/view.go new file mode 100644 index 00000000..06bca07c --- /dev/null +++ b/service/internal/reports/view.go @@ -0,0 +1,220 @@ +package reports + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "time" + + "github.com/jackc/pgx/v5" +) + +// View is a report as anyone may see it: GET /api/v1/reports/{id}. Until a +// report is published it is a receipt -- what arrived, not what it says. +type View struct { + Schema int `json:"schema"` + ID string `json:"id"` + ReceivedAt time.Time `json:"received_at"` + Channel string `json:"channel"` + Status + Consent string `json:"backup_consent"` + // The rest only once published. + Facts *Facts `json:"facts,omitempty"` + YAML string `json:"yaml,omitempty"` + Tool string `json:"tool,omitempty"` + Note string `json:"note,omitempty"` + Files []ViewFile `json:"files"` + // Boards the report was matched to, and how many other published + // reports come from the same physical board. + Models []ViewModel `json:"models"` + SameBoard int `json:"same_board"` +} + +type ViewFile struct { + Kind string `json:"kind"` + Name string `json:"name"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256,omitempty"` + URL string `json:"url,omitempty"` + // Private: stored for OpenIPC's maintainers and never served (a backup + // its owner did not agree to share). + Private bool `json:"private,omitempty"` +} + +type ViewModel struct { + ID string `json:"id"` + Model string `json:"model"` + Manufacturer string `json:"manufacturer"` +} + +// Public reads the view of one report. +func (s *Store) Public(ctx context.Context, id string) (*View, error) { + v := &View{Schema: 1, ID: id, Files: []ViewFile{}, Models: []ViewModel{}} + var f Facts + var hashes []byte + err := s.DB.QueryRow(ctx, ` + SELECT received_at, channel, backup_consent, tool, note, yaml_public, + chip_vendor, chip_model, sensor, flash_id, flash_size, board_vendor, board_model, main_app, id_hashes + FROM reports WHERE id = $1`, id).Scan(&v.ReceivedAt, &v.Channel, &v.Consent, &v.Tool, &v.Note, &v.YAML, + &f.ChipVendor, &f.ChipModel, &f.Sensor, &f.FlashID, &f.FlashSize, &f.BoardVendor, &f.BoardModel, &f.MainApp, &hashes) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if v.Status, err = s.status(ctx, s.DB, id); err != nil { + return nil, err + } + published := v.State == "published" + if !published { + v.YAML, v.Tool, v.Note = "", "", "" + } else { + v.Facts = &f + } + rows, err := s.DB.Query(ctx, ` + SELECT position, kind, name, bytes, public_sha256, public_bytes + FROM report_files WHERE report_id = $1 ORDER BY position`, id) + if err != nil { + return nil, err + } + for rows.Next() { + var pos int + var file ViewFile + var pub *string + var pubBytes *int64 + if err := rows.Scan(&pos, &file.Kind, &file.Name, &file.Bytes, &pub, &pubBytes); err != nil { + return nil, err + } + file.Private = pub == nil + if published && pub != nil { + file.SHA256, file.Bytes = *pub, *pubBytes + file.URL = "/api/v1/reports/" + id + "/files/" + strconv.Itoa(pos) + } + v.Files = append(v.Files, file) + } + if err := rows.Err(); err != nil { + return nil, err + } + if !published { + return v, nil + } + mrows, err := s.DB.Query(ctx, ` + SELECT m.id, m.model, mf.name FROM report_models rm + JOIN board_models m ON m.id = rm.model_id JOIN board_manufacturers mf ON mf.id = m.manufacturer_id + WHERE rm.report_id = $1 ORDER BY m.id`, id) + if err != nil { + return nil, err + } + if v.Models, err = pgx.CollectRows(mrows, pgx.RowToStructByPos[ViewModel]); err != nil { + return nil, err + } + var ids map[string]string + _ = json.Unmarshal(hashes, &ids) + if len(ids) > 0 { + // Another published report sharing any identifier's keyed hash is + // the same board, reported again. + err = s.DB.QueryRow(ctx, ` + SELECT count(*) FROM reports r + WHERE r.id <> $1 + AND EXISTS (SELECT 1 FROM jsonb_each_text(r.id_hashes) a JOIN jsonb_each_text($2::jsonb) b USING (key, value)) + AND (SELECT decision FROM report_reviews rv WHERE rv.report_id = r.id ORDER BY rv.id DESC LIMIT 1) = 'publish'`, + id, hashes).Scan(&v.SameBoard) + if err != nil { + return nil, err + } + } + return v, nil +} + +// Served is a published report's file, for the download handler: the stored +// copy that may be served, or ErrNotFound. +func (s *Store) Served(ctx context.Context, id string, position int) (sum, name, mime, kind string, err error) { + st, err := s.status(ctx, s.DB, id) + if err != nil { + return + } + if st.State != "published" { + err = ErrNotFound + return + } + var pub *string + err = s.DB.QueryRow(ctx, `SELECT public_sha256, name, mime, kind FROM report_files WHERE report_id = $1 AND position = $2`, + id, position).Scan(&pub, &name, &mime, &kind) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && pub == nil) { + err = ErrNotFound + return + } + if err == nil { + sum = *pub + } + return +} + +// Listed is one line of `openipc reports list`. +type Listed struct { + ID string `json:"id"` + ReceivedAt time.Time `json:"received_at"` + Channel string `json:"channel"` + Status string `json:"status"` + Chip string `json:"chip"` + Sensor string `json:"sensor"` + Board string `json:"board"` + Files int `json:"files"` + Consent string `json:"backup_consent"` + Models []string `json:"models"` +} + +// List is the review queue: every report, newest first, or those in one +// state. +func (s *Store) List(ctx context.Context, state string) ([]Listed, error) { + rows, err := s.DB.Query(ctx, ` + SELECT r.id, r.received_at, r.channel, + coalesce((SELECT CASE decision WHEN 'publish' THEN 'published' WHEN 'reject' THEN 'rejected' ELSE 'withdrawn' END + FROM report_reviews rv WHERE rv.report_id = r.id ORDER BY rv.id DESC LIMIT 1), 'pending'), + trim(r.chip_vendor || ' ' || r.chip_model), r.sensor, trim(r.board_vendor || ' ' || r.board_model), + (SELECT count(*) FROM report_files f WHERE f.report_id = r.id)::int, r.backup_consent, + coalesce((SELECT array_agg(model_id ORDER BY model_id) FROM report_models rm WHERE rm.report_id = r.id), '{}') + FROM reports r ORDER BY r.received_at DESC`) + if err != nil { + return nil, err + } + all, err := pgx.CollectRows(rows, pgx.RowToStructByPos[Listed]) + if err != nil || state == "" { + return all, err + } + var out []Listed + for _, l := range all { + if l.Status == state { + out = append(out, l) + } + } + return out, nil +} + +// Private reads a report whole -- the YAML as sent, every file -- for the +// reviewer's `openipc reports show`. Never served. +func (s *Store) Private(ctx context.Context, id string) (*Report, error) { + r := &Report{ID: id} + var hashes []byte + err := s.DB.QueryRow(ctx, ` + SELECT received_at, channel, tool, note, yaml, yaml_public, backup_consent, id_hashes + FROM reports WHERE id = $1`, id).Scan(&r.ReceivedAt, &r.Channel, &r.Tool, &r.Note, &r.YAML, &r.YAMLPublic, + &r.Consent, &hashes) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + _ = json.Unmarshal(hashes, &r.IDHashes) + rows, err := s.DB.Query(ctx, ` + SELECT position, kind, name, mime, sha256, bytes, coalesce(public_sha256, ''), coalesce(public_bytes, 0) + FROM report_files WHERE report_id = $1 ORDER BY position`, id) + if err != nil { + return nil, err + } + r.Files, err = pgx.CollectRows(rows, pgx.RowToStructByPos[File]) + return r, err +} diff --git a/service/routes.json b/service/routes.json index 80ee0d09..194e639a 100644 --- a/service/routes.json +++ b/service/routes.json @@ -109,6 +109,26 @@ "method": "GET", "path": "/api/v1/vendor-firmware/{deviceId}" }, + { + "role": "web", + "method": "POST", + "path": "/api/v1/reports" + }, + { + "role": "web", + "method": "GET", + "path": "/api/v1/reports/{id}" + }, + { + "role": "web", + "method": "GET", + "path": "/api/v1/reports/{id}/files/{position}" + }, + { + "role": "web", + "method": "POST", + "path": "/api/v1/boards/identify" + }, { "role": "firmware", "method": "GET", From 5b527d3945a833f6967048a4fe76ba6da5f9709c Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 29 Sep 2026 12:07:14 +0000 Subject: [PATCH 02/10] Owner reports: the nginx check's fixture may name the reports' directory --- service/deploytest/reports_test.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/service/deploytest/reports_test.go b/service/deploytest/reports_test.go index eefac9f4..a2ad4ff2 100644 --- a/service/deploytest/reports_test.go +++ b/service/deploytest/reports_test.go @@ -36,11 +36,13 @@ func TestOnlyTheReportsPackageTouchesReports(t *testing.T) { // owned by uid 1000, the backup that copies it off the host, and nginx's // internal location that sends a published file. mayNameRoot := map[string]bool{ - "service/internal/config/config.go": true, - "deploy/docker-compose.yml": true, - "deploy/install-go-service.sh": true, - "deploy/deploy.sh": true, - "deploy/backup-db.sh": true, + "service/internal/config/config.go": true, + "deploy/docker-compose.yml": true, + "deploy/install-go-service.sh": true, + "deploy/deploy.sh": true, + "deploy/backup-db.sh": true, + // a fixture file in its throwaway container, to prove /report-files/ is internal + "deploy/nginx/check-config.sh": true, "deploy/refresh-dev.sh": true, "deploy/nginx/sites-available/org.openipc": true, "deploy/nginx/sites-available/org.openipc.dev": true, From df877abfdb482b8936b6a8bd30816e7c3b02cadc Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 29 Sep 2026 12:10:56 +0000 Subject: [PATCH 03/10] Owner reports: the receipt test finds each file by its kind, not its order --- service/internal/reports/api_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/service/internal/reports/api_test.go b/service/internal/reports/api_test.go index bf65d667..44f91783 100644 --- a/service/internal/reports/api_test.go +++ b/service/internal/reports/api_test.go @@ -142,8 +142,12 @@ func TestAReportIsAReceiptUntilPublishedAndThenNamesNoCamera(t *testing.T) { t.Errorf("%s is in the public report", secret) } } - files := v["files"].([]any) - backup, photo, log := files[0].(map[string]any), files[1].(map[string]any), files[2].(map[string]any) + // parts arrive in map order; find each by its kind + byKind := map[string]map[string]any{} + for _, f := range v["files"].([]any) { + byKind[f.(map[string]any)["kind"].(string)] = f.(map[string]any) + } + backup, photo, log := byKind["backup"], byKind["photo"], byKind["boot_log"] if backup["private"] != true || backup["url"] != nil { t.Errorf("the private backup is offered: %v", backup) } From 831845007d3c00b046d28dcac2ed8cbd615a4f0c Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 29 Sep 2026 12:16:38 +0000 Subject: [PATCH 04/10] Owner reports: a note sent as a field is the report's note, not a note file ipctool upload --note on dev stored the note as a 25-byte file: the part name is both a field and a file kind. A part with a filename is a file, one without is a field. --- service/internal/reports/api_test.go | 30 ++++++++++++++++++++++++++++ service/internal/reports/handler.go | 15 ++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/service/internal/reports/api_test.go b/service/internal/reports/api_test.go index 44f91783..5836c73a 100644 --- a/service/internal/reports/api_test.go +++ b/service/internal/reports/api_test.go @@ -359,3 +359,33 @@ func TestVerifyFindsAChangedFile(t *testing.T) { t.Errorf("verify missed the change: %v", bad) } } + +// ipctool sends --note as a field; a note file is a part with a filename. +// The two share a name, and the field must not become a file. +func TestANoteFieldIsTheReportsNoteAndANoteFileIsAFile(t *testing.T) { + e := newEnv(t) + var body bytes.Buffer + mw := multipart.NewWriter(&body) + _ = mw.WriteField("yaml", fixture(t, "t31-sc2332.txt")) + _ = mw.WriteField("note", "bought at a market in Shenzhen") + w, _ := mw.CreateFormFile("note", "findings.txt") + w.Write([]byte("UART pads next to the flash\n")) + mw.Close() + req := httptest.NewRequest("POST", "/api/v1/reports", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.RemoteAddr = "203.0.113.12:1" + rec := httptest.NewRecorder() + e.mux.ServeHTTP(rec, req) + var out struct { + ID string `json:"id"` + Files []struct{ Kind, Name string } + } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if rec.Code != 201 || len(out.Files) != 1 || out.Files[0].Name != "findings.txt" { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + r, _ := (&Store{DB: e.pool}).Private(context.Background(), out.ID) + if r.Note != "bought at a market in Shenzhen" { + t.Errorf("note %q", r.Note) + } +} diff --git a/service/internal/reports/handler.go b/service/internal/reports/handler.go index 73b877cf..bec926d1 100644 --- a/service/internal/reports/handler.go +++ b/service/internal/reports/handler.go @@ -303,7 +303,16 @@ func (a *API) read(r *http.Request) (*received, int, error) { return in, http.StatusBadRequest, errors.New("the multipart body is cut short") } name := p.FormName() + // "note" is both a field (a line of text) and a file kind (a note + // file): a part with a filename is a file, one without is a field. + isField := p.FileName() == "" && (name == "consent" || name == "channel" || name == "tool" || name == "note") switch { + case isField: + b, err := io.ReadAll(io.LimitReader(p, maxField+1)) + if err != nil || len(b) > maxField || !utf8.Valid(b) { + return in, http.StatusBadRequest, fmt.Errorf("%s: at most %d characters of text", name, maxField) + } + in.fields[name] = strings.TrimSpace(string(b)) case name == "yaml": b, err := io.ReadAll(io.LimitReader(p, MaxYAML+1)) if err != nil || len(b) > MaxYAML { @@ -334,12 +343,6 @@ func (a *API) read(r *http.Request) (*received, int, error) { return in, http.StatusUnsupportedMediaType, fmt.Errorf("%s %s: %v", name, pt.name, err) } in.parts[len(in.parts)-1].mime = mt - case name == "consent" || name == "channel" || name == "tool" || name == "note": - b, err := io.ReadAll(io.LimitReader(p, maxField+1)) - if err != nil || len(b) > maxField || !utf8.Valid(b) { - return in, http.StatusBadRequest, fmt.Errorf("%s: at most %d characters of text", name, maxField) - } - in.fields[name] = strings.TrimSpace(string(b)) default: return in, http.StatusBadRequest, fmt.Errorf("%q is not a part a report has: yaml, backup, photo, boot_log, uboot_env, note, document, consent, channel, tool", name) } From d43c417ec6a8740264a18f3d04b75f4e6fa8e041 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 29 Sep 2026 12:23:15 +0000 Subject: [PATCH 05/10] Tools: ipctool's builds pushed by its release job, served over plain HTTP for uget A camera on stock firmware has no curl and no TLS, and uget, the downloader an owner pastes in over telnet, speaks HTTP on port 80 and follows no redirects. It cannot fetch GitHub's release links. OpenIPC/ ipctool's release job pushes each build to PUT /api/v1/tools/{name} over the builds' OIDC check. The service checks it is an ELF for the machine its name says and installs it atomically under /srv/www/shared/tools. nginx serves http://openipc.org/ipctool (-mips32, -arm64) on port 80; over HTTPS /ipctool still redirects to the project on GitHub. internal/tools/PUSH.md has the contract. --- deploy/deploy.sh | 12 +- deploy/docker-compose.yml | 2 + deploy/install-go-service.sh | 2 +- deploy/nginx/check-config.sh | 13 ++ deploy/nginx/sites-available/org.openipc | 11 + deploy/nginx/sites-available/org.openipc.dev | 11 + service/cmd/openipc/main.go | 8 + service/internal/builds/verify.go | 2 + service/internal/config/config.go | 5 + service/internal/db/migrations/017_tools.sql | 14 ++ service/internal/tools/PUSH.md | 47 ++++ service/internal/tools/tools.go | 215 +++++++++++++++++++ service/internal/tools/tools_test.go | 120 +++++++++++ service/internal/vendorfw/vendorfw_test.go | 10 +- service/routes.json | 10 + 15 files changed, 471 insertions(+), 11 deletions(-) create mode 100644 service/internal/db/migrations/017_tools.sql create mode 100644 service/internal/tools/PUSH.md create mode 100644 service/internal/tools/tools.go create mode 100644 service/internal/tools/tools_test.go diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 2c82853a..3c61467c 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -99,11 +99,11 @@ env_set() { warn() { printf '\033[33m==>\033[0m %s\n' "$*" >&2; } -# web firmware ports tag rollback file firmware cache release cache wall boards owner reports +# web firmware ports tag rollback file firmware cache release cache wall boards owner reports tools target_for() { case "$1" in - prod) echo "go-web-prod go-firmware-prod 3002 3003 GO_PROD_TAG .previous-prod /srv/www/shared/firmware /srv/www/shared/go-release-cache /srv/www/shared/wall /srv/www/shared/boards /srv/www/shared/owner-reports" ;; - dev) echo "go-web-dev go-firmware-dev 3012 3013 GO_DEV_TAG .previous-dev /srv/www/shared/dev-firmware /srv/www/shared/dev-go-release-cache /srv/www/shared/dev-wall /srv/www/shared/dev-boards /srv/www/shared/dev-owner-reports" ;; + prod) echo "go-web-prod go-firmware-prod 3002 3003 GO_PROD_TAG .previous-prod /srv/www/shared/firmware /srv/www/shared/go-release-cache /srv/www/shared/wall /srv/www/shared/boards /srv/www/shared/owner-reports /srv/www/shared/tools" ;; + dev) echo "go-web-dev go-firmware-dev 3012 3013 GO_DEV_TAG .previous-dev /srv/www/shared/dev-firmware /srv/www/shared/dev-go-release-cache /srv/www/shared/dev-wall /srv/www/shared/dev-boards /srv/www/shared/dev-owner-reports /srv/www/shared/dev-tools" ;; *) die "unknown target '$1' (expected prod or dev)" ;; esac } @@ -161,8 +161,8 @@ do_deploy() { # off $1 rather than env_name below so it can stay first. checkout_warn "$CHECKOUT_DIR" "$(checkout_branch_for "${1:-prod}")" local env_name=$1 sha=${2:-latest} - local web fw web_port fw_port tag_key prev_file fw_cache rel_cache wall_root boards_root reports_root - read -r web fw web_port fw_port tag_key prev_file fw_cache rel_cache wall_root boards_root reports_root <<<"$(target_for "$env_name")" + local web fw web_port fw_port tag_key prev_file fw_cache rel_cache wall_root boards_root reports_root tools_root + read -r web fw web_port fw_port tag_key prev_file fw_cache rel_cache wall_root boards_root reports_root tools_root <<<"$(target_for "$env_name")" local prev_path="${STATE_DIR}/${prev_file}" [ -f "/srv/www/.env.go-${env_name}" ] \ @@ -180,6 +180,8 @@ do_deploy() { # role. Not /srv/www/shared/reports: that is the analytics' directory, and # dev serves it to anyone with the staging password. ensure_uid_1000_root "$reports_root" + # ipctool's builds, pushed by its release job, served by nginx on port 80. + ensure_uid_1000_root "$tools_root" install_legacy_images local previous diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 15c82ea0..eb09639d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -53,6 +53,7 @@ services: - /srv/www/shared/wall:/srv/wall - /srv/www/shared/boards:/srv/boards - /srv/www/shared/owner-reports:/srv/owner-reports + - /srv/www/shared/tools:/srv/tools go-firmware-prod: <<: *go-common @@ -90,6 +91,7 @@ services: - /srv/www/shared/dev-wall:/srv/wall - /srv/www/shared/dev-boards:/srv/boards - /srv/www/shared/dev-owner-reports:/srv/owner-reports + - /srv/www/shared/dev-tools:/srv/tools go-firmware-dev: <<: *go-common diff --git a/deploy/install-go-service.sh b/deploy/install-go-service.sh index db7cb353..2241e25c 100755 --- a/deploy/install-go-service.sh +++ b/deploy/install-go-service.sh @@ -177,7 +177,7 @@ ensure_keys() { } make_directories() { - for d in firmware dev-firmware go-release-cache dev-go-release-cache wall dev-wall boards dev-boards owner-reports dev-owner-reports; do + for d in firmware dev-firmware go-release-cache dev-go-release-cache wall dev-wall boards dev-boards owner-reports dev-owner-reports tools dev-tools; do install -d -o 1000 -g 1000 -m 0755 "${SHARED}/${d}" done ok "directories under ${SHARED}" diff --git a/deploy/nginx/check-config.sh b/deploy/nginx/check-config.sh index bbfe5038..b93589cc 100755 --- a/deploy/nginx/check-config.sh +++ b/deploy/nginx/check-config.sh @@ -122,6 +122,8 @@ server { listen 127.0.0.1:3013; location / { return 200 "GO-FIRMWARE-DEV\n"; } } STUB install -d -m 0755 /srv/www/shared/firmware printf 'IMAGE\n' > /srv/www/shared/firmware/image.bin +install -d -m 0755 /srv/www/shared/tools +printf 'IPCTOOL-ARM\n' > /srv/www/shared/tools/ipctool install -d -m 0755 /srv/www/shared/owner-reports/sha256/ab printf 'REPORT-FILE\n' > /srv/www/shared/owner-reports/sha256/ab/abcd @@ -590,6 +592,17 @@ for probe in "200 POST /api/v1/reports" "200 POST /api/v1/boards/identify" "301 fail=1 fi done +# ipctool for uget: the file over plain HTTP, never a redirect; over HTTPS +# /ipctool is still the way to the project on GitHub. +got=$(curl -sS -o /tmp/it -w '%{http_code}' --max-time 5 --http1.0 \ + --resolve "openipc.org:80:127.0.0.1" "http://openipc.org/ipctool" 2>/dev/null) +if [ "$got" = 200 ] && grep -q IPCTOOL-ARM /tmp/it; then + printf ' %-32s %-5s (the binary, plain HTTP/1.0)\n' /ipctool "$got" +else + printf ' %-32s %-5s MISMATCH: uget cannot fetch ipctool\n' /ipctool "$got" + fail=1 +fi +redirects_to openipc.org /ipctool https://github.com/OpenIPC/ipctool/ expect /api/v1/reports/r-x 200 go hsts expect /api/v1/reports/r-test/files/1 200 go hsts grep -q REPORT-FILE /tmp/b || { echo " a report's file did not reach /report-files/"; fail=1; } diff --git a/deploy/nginx/sites-available/org.openipc b/deploy/nginx/sites-available/org.openipc index 965b23d0..7b1df11d 100644 --- a/deploy/nginx/sites-available/org.openipc +++ b/deploy/nginx/sites-available/org.openipc @@ -24,6 +24,17 @@ server { return 301 https://$host$request_uri; } + # ipctool's builds for a camera on stock firmware, whose only downloader + # is uget: plain HTTP, port 80, no redirects (internal/tools). Over HTTPS + # /ipctool still leads a browser to the project on GitHub. + location ~ ^/(ipctool|ipctool-mips32|ipctool-arm64)$ { + alias /srv/www/shared/tools/$1; + default_type application/octet-stream; + add_header Cache-Control "no-cache" always; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By nginx always; + } + # ipctool uploads a report over plain HTTP: stock camera firmware has no # TLS. Only the upload and identify are answered here; everything else # redirects. diff --git a/deploy/nginx/sites-available/org.openipc.dev b/deploy/nginx/sites-available/org.openipc.dev index a5d4eac5..4ca4d332 100644 --- a/deploy/nginx/sites-available/org.openipc.dev +++ b/deploy/nginx/sites-available/org.openipc.dev @@ -24,6 +24,17 @@ server { return 301 https://$host$request_uri; } + # ipctool's builds for a camera on stock firmware, whose only downloader + # is uget: plain HTTP, port 80, no redirects (internal/tools). Over HTTPS + # /ipctool still leads a browser to the project on GitHub. + location ~ ^/(ipctool|ipctool-mips32|ipctool-arm64)$ { + alias /srv/www/shared/dev-tools/$1; + default_type application/octet-stream; + add_header Cache-Control "no-cache" always; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By nginx always; + } + # ipctool uploads a report over plain HTTP: stock camera firmware has no # TLS. Only the upload and identify are answered here; everything else # redirects. diff --git a/service/cmd/openipc/main.go b/service/cmd/openipc/main.go index 589b7731..0a3f8c3d 100644 --- a/service/cmd/openipc/main.go +++ b/service/cmd/openipc/main.go @@ -43,6 +43,7 @@ import ( "github.com/OpenIPC/website/service/internal/purge" "github.com/OpenIPC/website/service/internal/reports" "github.com/OpenIPC/website/service/internal/snapshots" + "github.com/OpenIPC/website/service/internal/tools" "github.com/OpenIPC/website/service/internal/variants" "github.com/OpenIPC/website/service/internal/vendorfw" "github.com/OpenIPC/website/service/internal/wall" @@ -167,6 +168,8 @@ var routes = []Route{ {"web", "GET", "/api/v1/reports/{id}"}, {"web", "GET", "/api/v1/reports/{id}/files/{position}"}, {"web", "POST", "/api/v1/boards/identify"}, + {"web", "PUT", "/api/v1/tools/{name}"}, + {"web", "GET", "/api/v1/tools"}, {"firmware", "GET", "/cameras/vendors/{vendor}/socs/{soc}/download_full_image"}, {"firmware", "GET", "/{locale}/cameras/vendors/{vendor}/socs/{soc}/download_full_image"}, } @@ -361,6 +364,11 @@ func web(ctx context.Context, cfg *config.Config, log *slog.Logger, pool *pgxpoo AccelPrefix: cfg.ReportsAccelPrefix, Log: log}).Handlers() { handlers[k] = h } + // ipctool's builds, pushed by its release job (tools/PUSH.md). + for k, h := range (&tools.API{Verifier: &builds.LazyVerifier{Issuer: builds.GitHubIssuer}, + DB: pool, Root: cfg.ToolsRoot, Log: log}).Handlers() { + handlers[k] = h + } for _, r := range routes { if r.Role != "web" { continue diff --git a/service/internal/builds/verify.go b/service/internal/builds/verify.go index 424063cb..26ea9c73 100644 --- a/service/internal/builds/verify.go +++ b/service/internal/builds/verify.go @@ -43,6 +43,8 @@ var pushers = map[string][]string{ "coupler": {"OpenIPC/coupler/.github/workflows/xm.yml@refs/heads/main"}, // Anjoy Vision's firmware builds, keyed by board model (vendorfw/PUSH.md). "anjoyupdates": {"OpenIPC/anjoyupdates/.github/workflows/weekly-update.yml@refs/heads/main"}, + // ipctool's static builds, served to cameras over plain HTTP (tools/PUSH.md). + "ipctool": {"OpenIPC/ipctool/.github/workflows/release.yml@refs/heads/master"}, } // Verifier checks a bearer token end to end. Keys come from the issuer's diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 80c1e60e..3bb1236a 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -37,6 +37,10 @@ type Config struct { // hands a published file to nginx's internal location ReportsAccelPrefix. ReportsRoot string ReportsAccelPrefix string + // ipctool's builds, pushed by its release job and served by nginx over + // plain HTTP at http://openipc.org/ (internal/tools); the NFS role + // exports the same directory read-only. + ToolsRoot string // Firmware role. CatalogueDir string @@ -66,6 +70,7 @@ func Load() (*Config, error) { BoardsRoot: str("BOARDS_ROOT", "/srv/boards"), ReportsRoot: str("REPORTS_ROOT", "/srv/owner-reports"), ReportsAccelPrefix: str("REPORTS_ACCEL_PREFIX", "/report-files/"), + ToolsRoot: str("TOOLS_ROOT", "/srv/tools"), CatalogueDir: str("CATALOGUE_DIR", "/app/catalogue"), ReleaseCacheRoot: str("RELEASE_CACHE_ROOT", "/srv/release-cache"), FirmwareCacheRoot: str("FIRMWARE_CACHE_ROOT", "/srv/firmware"), diff --git a/service/internal/db/migrations/017_tools.sql b/service/internal/db/migrations/017_tools.sql new file mode 100644 index 00000000..ad342013 --- /dev/null +++ b/service/internal/db/migrations/017_tools.sql @@ -0,0 +1,14 @@ +-- Tools a camera fetches from openipc.org over plain HTTP: ipctool's builds, +-- pushed by OpenIPC/ipctool's release job (internal/tools/PUSH.md). Stock +-- camera firmware has no curl and no TLS; uget, the downloader an owner +-- pastes in over telnet, speaks HTTP on port 80 and follows no redirects, so +-- GitHub's release links are out of its reach. nginx serves the files at +-- http://openipc.org/; this is what is there. +CREATE TABLE tools ( + name text PRIMARY KEY CHECK (name IN ('ipctool', 'ipctool-mips32', 'ipctool-arm64')), + version text NOT NULL CHECK (version ~ '^[A-Za-z0-9._+-]{1,64}$'), + sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + bytes bigint NOT NULL CHECK (bytes > 0), + pushed_by text NOT NULL, + pushed_at timestamptz NOT NULL DEFAULT now() +); diff --git a/service/internal/tools/PUSH.md b/service/internal/tools/PUSH.md new file mode 100644 index 00000000..b042ca65 --- /dev/null +++ b/service/internal/tools/PUSH.md @@ -0,0 +1,47 @@ +# Pushing ipctool's builds to openipc.org + +A camera on stock firmware has no curl and no TLS. The downloader its owner +pastes in over telnet, [uget](https://github.com/OpenIPC/uget), speaks HTTP on +port 80 and follows no redirects, so it cannot fetch a GitHub release. So +openipc.org serves ipctool itself: + +``` +http://openipc.org/ipctool arm32 (HiSilicon, Goke, SigmaStar, XM, ...) +http://openipc.org/ipctool-mips32 Ingenic +http://openipc.org/ipctool-arm64 Hi3519DV500 and other aarch64 +``` + +The same files are what the NFS role exports read-only. + +Nothing on openipc.org fetches them. OpenIPC/ipctool's release job pushes +each build once, the way firmware builds are pushed (`internal/builds/PUSH.md`): + +``` +PUT /api/v1/tools/{ipctool|ipctool-mips32|ipctool-arm64}?version= +Authorization: Bearer +X-Content-SHA256: (optional; checked when sent) +Content-Type: application/octet-stream + + +``` + +- The token must come from `OpenIPC/ipctool/.github/workflows/release.yml` on + `refs/heads/master`, the job that publishes `latest`. A tag or pull-request + run is refused (403). +- The body must be an ELF executable for the machine its name says: ARM for + `ipctool`, MIPS for `-mips32`, AArch64 for `-arm64` (400 otherwise). +- The file is replaced atomically. A camera fetching during a push gets the + old file or the new one. +- `200 {"name","version","sha256","bytes"}` on success. `GET /api/v1/tools` + lists what is served. + +In the workflow (needs `permissions: id-token: write`): + +```sh +T=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://openipc.org" | jq -r .value) +curl -fsS -X PUT -H "Authorization: Bearer $T" \ + -H "X-Content-SHA256: $(sha256sum build/ipctool | cut -d' ' -f1)" \ + --data-binary @build/ipctool \ + "https://openipc.org/api/v1/tools/ipctool${SUFFIX}?version=$GIT_HASH" +``` diff --git a/service/internal/tools/tools.go b/service/internal/tools/tools.go new file mode 100644 index 00000000..626a8ba2 --- /dev/null +++ b/service/internal/tools/tools.go @@ -0,0 +1,215 @@ +// Package tools is what a camera on stock firmware fetches from openipc.org: +// ipctool's static builds, pushed once per release by OpenIPC/ipctool's CI +// (PUSH.md) and served by nginx over plain HTTP at http://openipc.org/, +// because the downloader such a camera has -- uget, pasted in over telnet -- +// speaks nothing else. The same directory is what the NFS role exports. +package tools + +import ( + "bytes" + "context" + "crypto/sha256" + "debug/elf" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/OpenIPC/website/service/internal/builds" +) + +// Names are the files, and the machine each must be built for. +var Names = map[string]elf.Machine{ + "ipctool": elf.EM_ARM, + "ipctool-mips32": elf.EM_MIPS, + "ipctool-arm64": elf.EM_AARCH64, +} + +// MaxBytes: a UPX-packed ipctool is ~200 KB, unpacked under 1.5 MB. +const MaxBytes = 4 << 20 + +var version = regexp.MustCompile(`^[A-Za-z0-9._+-]{1,64}$`) + +// API is PUT /api/v1/tools/{name} (the push) and GET /api/v1/tools (what is +// there). +type API struct { + Verifier interface { + Verify(ctx context.Context, raw string) (*builds.Claims, error) + } + DB *pgxpool.Pool + Root string // TOOLS_ROOT + Log *slog.Logger +} + +func (a *API) Handlers() map[string]http.Handler { + return map[string]http.Handler{ + "PUT /api/v1/tools/{name}": http.HandlerFunc(a.push), + "GET /api/v1/tools": http.HandlerFunc(a.list), + } +} + +func (a *API) push(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + want, ok := Names[name] + if !ok { + a.refuse(w, http.StatusNotFound, fmt.Sprintf("%q is not a tool openipc.org serves", name), nil) + return + } + raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || raw == "" { + a.refuse(w, http.StatusUnauthorized, "a GitHub Actions OIDC token is required as a bearer token", nil) + return + } + claims, err := a.Verifier.Verify(r.Context(), strings.TrimSpace(raw)) + if err != nil { + var forbidden builds.ErrForbidden + var unavailable builds.ErrUnavailable + switch { + case errors.As(err, &forbidden): + a.refuse(w, http.StatusForbidden, forbidden.Reason, nil) + case errors.As(err, &unavailable): + a.refuse(w, http.StatusServiceUnavailable, "tokens cannot be verified right now; retry", err) + default: + a.refuse(w, http.StatusUnauthorized, "the token did not verify", err) + } + return + } + if err := claims.Allows("ipctool"); err != nil { + a.refuse(w, http.StatusForbidden, err.Error(), nil) + return + } + ver := r.URL.Query().Get("version") + if !version.MatchString(ver) { + a.refuse(w, http.StatusBadRequest, "?version= names the build: its commit or tag", nil) + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxBytes)) + if err != nil { + a.refuse(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("a tool is at most %d MB", MaxBytes>>20), nil) + return + } + if err := check(body, want); err != nil { + a.refuse(w, http.StatusBadRequest, name+": "+err.Error(), nil) + return + } + sum := sha256.Sum256(body) + hexSum := hex.EncodeToString(sum[:]) + if got := r.Header.Get("X-Content-SHA256"); got != "" && !strings.EqualFold(got, hexSum) { + a.refuse(w, http.StatusBadRequest, "the body is not the file X-Content-SHA256 names", nil) + return + } + if err := Install(a.Root, name, body); err != nil { + a.Log.Error("tools: not installed", "name", name, "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "the file could not be stored; retry"}) + return + } + if _, err := a.DB.Exec(r.Context(), ` + INSERT INTO tools (name, version, sha256, bytes, pushed_by) VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (name) DO UPDATE SET version = $2, sha256 = $3, bytes = $4, pushed_by = $5, pushed_at = now()`, + name, ver, hexSum, len(body), claims.PushedBy()); err != nil { + a.Log.Error("tools: not recorded", "name", name, "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "the file is in place but not recorded; retry"}) + return + } + a.Log.Info("tools: installed", "name", name, "version", ver, "bytes", len(body), "by", claims.PushedBy()) + writeJSON(w, http.StatusOK, map[string]any{"name": name, "version": ver, "sha256": hexSum, "bytes": len(body)}) +} + +// check: an ELF for the machine the name says. A mips build under the arm +// name would be served to every HiSilicon camera and fail on each. +func check(b []byte, want elf.Machine) error { + f, err := elf.NewFile(bytes.NewReader(b)) + if err != nil { + return errors.New("not an ELF executable") + } + if f.Machine != want { + return fmt.Errorf("built for %v, not %v", f.Machine, want) + } + if f.Type != elf.ET_EXEC && f.Type != elf.ET_DYN { + return errors.New("not an executable") + } + return nil +} + +// Install replaces root/name atomically: a camera fetching mid-push gets the +// old file or the new one, never half of either. +func Install(root, name string, body []byte) error { + if err := os.MkdirAll(root, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(root, "."+name+".*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(body); err == nil { + err = tmp.Sync() + } + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + if err := os.Chmod(tmp.Name(), 0o755); err != nil { + return err + } + return os.Rename(tmp.Name(), filepath.Join(root, name)) +} + +// Tool is one row of GET /api/v1/tools. +type Tool struct { + Name string `json:"name"` + URL string `json:"url"` + Version string `json:"version"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` + PushedAt time.Time `json:"pushed_at"` +} + +func (a *API) list(w http.ResponseWriter, r *http.Request) { + rows, err := a.DB.Query(r.Context(), `SELECT name, version, sha256, bytes, pushed_at FROM tools ORDER BY name`) + if err != nil { + a.Log.Error("tools: list", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "try again"}) + return + } + defer rows.Close() + out := []Tool{} + for rows.Next() { + var t Tool + if err := rows.Scan(&t.Name, &t.Version, &t.SHA256, &t.Bytes, &t.PushedAt); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "try again"}) + return + } + t.URL = "http://openipc.org/" + t.Name + out = append(out, t) + } + writeJSON(w, http.StatusOK, map[string]any{"schema": 1, "tools": out}) +} + +func (a *API) refuse(w http.ResponseWriter, status int, reason string, err error) { + args := []any{"status", status, "reason", reason} + if err != nil { + args = append(args, "err", err) + } + a.Log.Warn("tools: push refused", args...) + writeJSON(w, status, map[string]string{"error": reason}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/service/internal/tools/tools_test.go b/service/internal/tools/tools_test.go new file mode 100644 index 00000000..73793dd3 --- /dev/null +++ b/service/internal/tools/tools_test.go @@ -0,0 +1,120 @@ +package tools + +import ( + "bytes" + "context" + "debug/elf" + "encoding/binary" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/OpenIPC/website/service/internal/builds" + "github.com/OpenIPC/website/service/internal/db/dbtest" +) + +type fakeVerifier struct { + c *builds.Claims + err error +} + +func (f fakeVerifier) Verify(context.Context, string) (*builds.Claims, error) { return f.c, f.err } + +var release = &builds.Claims{Repository: "OpenIPC/ipctool", JobWorkflowRef: "OpenIPC/ipctool/.github/workflows/release.yml@refs/heads/master", RunID: "1", RunAttempt: "1"} + +// exe is the smallest executable ELF header debug/elf reads. +func exe(class elf.Class, m elf.Machine, tail string) []byte { + var b bytes.Buffer + b.Write([]byte{0x7f, 'E', 'L', 'F', byte(class), 1, 1, 0}) + b.Write(make([]byte, 8)) + le := binary.LittleEndian + _ = binary.Write(&b, le, uint16(elf.ET_EXEC)) + _ = binary.Write(&b, le, uint16(m)) + _ = binary.Write(&b, le, uint32(1)) + if class == elf.ELFCLASS32 { + _ = binary.Write(&b, le, [3]uint32{}) // entry, phoff, shoff + _ = binary.Write(&b, le, uint32(0)) // flags + _ = binary.Write(&b, le, [6]uint16{52, 32, 0, 40, 0, 0}) + } else { + _ = binary.Write(&b, le, [3]uint64{}) + _ = binary.Write(&b, le, uint32(0)) + _ = binary.Write(&b, le, [6]uint16{64, 56, 0, 64, 0, 0}) + } + b.WriteString(tail) + return b.Bytes() +} + +func TestAReleasePushReplacesTheFileCamerasFetch(t *testing.T) { + pool := dbtest.New(t) + root := t.TempDir() + v := fakeVerifier{c: release} + api := &API{Verifier: &v, DB: pool, Root: root, Log: slog.New(slog.NewTextHandler(io.Discard, nil))} + mux := http.NewServeMux() + for k, h := range api.Handlers() { + mux.Handle(k, h) + } + put := func(name, ver string, body []byte) *httptest.ResponseRecorder { + req := httptest.NewRequest("PUT", "/api/v1/tools/"+name+"?version="+ver, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer t") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec + } + arm1, arm2 := exe(elf.ELFCLASS32, elf.EM_ARM, "one"), exe(elf.ELFCLASS32, elf.EM_ARM, "two") + if rec := put("ipctool", "ac57899", arm1); rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if rec := put("ipctool", "51fe1fe", arm2); rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + got, _ := os.ReadFile(filepath.Join(root, "ipctool")) + info, _ := os.Stat(filepath.Join(root, "ipctool")) + if !bytes.Equal(got, arm2) || info.Mode().Perm() != 0o755 { + t.Errorf("installed %d bytes, mode %v", len(got), info.Mode()) + } + if rec := put("ipctool-mips32", "x", exe(elf.ELFCLASS32, elf.EM_MIPS, "")); rec.Code != 200 { + t.Errorf("mips: %d", rec.Code) + } + if rec := put("ipctool-arm64", "x", exe(elf.ELFCLASS64, elf.EM_AARCH64, "")); rec.Code != 200 { + t.Errorf("arm64: %d %s", rec.Code, rec.Body) + } + + for name, c := range map[string]struct { + tool string + body []byte + want int + }{ + "mips build under the arm name": {"ipctool", exe(elf.ELFCLASS32, elf.EM_MIPS, ""), 400}, + "not an ELF": {"ipctool", []byte("#!/bin/sh\necho hi\n"), 400}, + "a name nobody serves": {"busybox", arm1, 404}, + } { + if rec := put(c.tool, "x", c.body); rec.Code != c.want { + t.Errorf("%s: %d, want %d", name, rec.Code, c.want) + } + } + v.c = &builds.Claims{Repository: "OpenIPC/ipctool", JobWorkflowRef: "OpenIPC/ipctool/.github/workflows/pr-build-check.yml@refs/pull/9/merge"} + if rec := put("ipctool", "x", arm1); rec.Code != 403 { + t.Errorf("a PR workflow pushed: %d", rec.Code) + } + v.c, v.err = nil, errors.New("bad signature") + if rec := put("ipctool", "x", arm1); rec.Code != 401 { + t.Errorf("an unverified token pushed: %d", rec.Code) + } + if got, _ := os.ReadFile(filepath.Join(root, "ipctool")); !bytes.Equal(got, arm2) { + t.Error("a refused push changed the file") + } + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/tools", nil)) + var out struct{ Tools []Tool } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if len(out.Tools) != 3 || out.Tools[0].Name != "ipctool" || out.Tools[0].Version != "51fe1fe" || out.Tools[0].URL != "http://openipc.org/ipctool" { + t.Errorf("list: %s", rec.Body) + } +} diff --git a/service/internal/vendorfw/vendorfw_test.go b/service/internal/vendorfw/vendorfw_test.go index 3de4ba36..0807dee6 100644 --- a/service/internal/vendorfw/vendorfw_test.go +++ b/service/internal/vendorfw/vendorfw_test.go @@ -298,12 +298,12 @@ func TestAModelKeyedPushNamesADeviceType(t *testing.T) { t.Fatalf("a good push: %v", err) } for name, it := range map[string]Item{ - "an XM device ID": func() Item { i := good; i.DeviceID = "000559A7"; return i }(), - "no device type": func() Item { i := good; i.DeviceType = ""; return i }(), - "a bad category": func() Item { i := good; i.Category = "toaster"; return i }(), + "an XM device ID": func() Item { i := good; i.DeviceID = "000559A7"; return i }(), + "no device type": func() Item { i := good; i.DeviceType = ""; return i }(), + "a bad category": func() Item { i := good; i.Category = "toaster"; return i }(), "an unknown collection": func() Item { i := good; i.Collection = "pre-2019"; return i }(), - "a variant in Latin": func() Item { i := good; i.Variant = map[string]string{"la": "x"}; return i }(), - "another repo": func() Item { i := good; i.AssetURL = Sources["xmupdates"] + "x/r12.bin"; return i }(), + "a variant in Latin": func() Item { i := good; i.Variant = map[string]string{"la": "x"}; return i }(), + "another repo": func() Item { i := good; i.AssetURL = Sources["xmupdates"] + "x/r12.bin"; return i }(), } { if _, err := Decode(body(t, "anjoyupdates", it)); err == nil { t.Errorf("%s: accepted", name) diff --git a/service/routes.json b/service/routes.json index 194e639a..76e647e7 100644 --- a/service/routes.json +++ b/service/routes.json @@ -129,6 +129,16 @@ "method": "POST", "path": "/api/v1/boards/identify" }, + { + "role": "web", + "method": "PUT", + "path": "/api/v1/tools/{name}" + }, + { + "role": "web", + "method": "GET", + "path": "/api/v1/tools" + }, { "role": "firmware", "method": "GET", From a9be4155b277ca442455a145a39f6be8958fc103 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 29 Sep 2026 12:30:24 +0000 Subject: [PATCH 06/10] NFS role: ipctool's builds, read-only, for stock firmware that can mount but not fetch `openipc serve --role nfs` answers what busybox's `mount -o nolock openipc.org:/ipctool /tmp/o` asks, with no other option: the portmapper on 111, then MOUNT v1/v3 and NFS v2/v3, over UDP and TCP. Everything that would write is refused as a read-only file system. No dependency. go-nfs is TCP only, with no portmapper, and a stock busybox asks both over UDP. NFS READ over UDP is a reflection amplifier, so a file handle is keyed to the address it was issued to. A spoofer cannot hold one that works from a victim's address, and UDP answers are budgeted per address as well. Tried on the lab Hi3516EV300 (busybox 1.36, kernel 4.9): mount -o nolock mounted over TCP, ipctool ran from the mount, and the md5 matched. Forced proto=udp,mountproto=udp,vers=3 mounted too, with rsize 8192. That kernel has no NFSv2, so v2 is covered by the unit test only. It runs as go-nfs beside production (one portmapper per host), started by deploy.sh prod. --- CLAUDE.md | 9 + deploy/deploy.sh | 5 + deploy/docker-compose.yml | 24 +++ service/README.md | 1 + service/cmd/openipc/main.go | 8 +- service/cmd/openipc/nfs.go | 71 +++++++ service/deploytest/tools_test.go | 51 +++++ service/internal/config/config.go | 6 + service/internal/nfsro/fs.go | 140 ++++++++++++++ service/internal/nfsro/mount.go | 86 +++++++++ service/internal/nfsro/nfs2.go | 144 ++++++++++++++ service/internal/nfsro/nfs3.go | 279 +++++++++++++++++++++++++++ service/internal/nfsro/nfsro_test.go | 265 +++++++++++++++++++++++++ service/internal/nfsro/server.go | 253 ++++++++++++++++++++++++ service/internal/nfsro/xdr.go | 76 ++++++++ 15 files changed, 1416 insertions(+), 2 deletions(-) create mode 100644 service/cmd/openipc/nfs.go create mode 100644 service/deploytest/tools_test.go create mode 100644 service/internal/nfsro/fs.go create mode 100644 service/internal/nfsro/mount.go create mode 100644 service/internal/nfsro/nfs2.go create mode 100644 service/internal/nfsro/nfs3.go create mode 100644 service/internal/nfsro/nfsro_test.go create mode 100644 service/internal/nfsro/server.go create mode 100644 service/internal/nfsro/xdr.go diff --git a/CLAUDE.md b/CLAUDE.md index b9d4b33d..dd04de11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,15 @@ restores the image but never the schema, so keep migrations additive. (`/srv/www/shared/owner-reports` -- not `shared/reports`, which is the analytics'), and `internal/boards/survival_test.go` runs every importer over stored reports. +- `internal/tools`, `internal/nfsro` — **ipctool for stock firmware**, which + has no curl and no TLS. ipctool's release job pushes each build to + `PUT /api/v1/tools/{name}` (OIDC, `internal/tools/PUSH.md`); nginx serves + them over plain HTTP at `http://openipc.org/ipctool` (`-mips32`, `-arm64`) + for uget, and `serve --role nfs` (the `go-nfs` container, production only) + exports the same directory read-only: portmapper, MOUNT v1/v3 and NFS v2/v3 + over UDP and TCP, so `mount -o nolock openipc.org:/ipctool /tmp/o` works + as busybox does it. Handles are keyed to the client's address, so the UDP + READ path cannot be used for reflection. - `internal/catalogue` — **the hardware catalogue is `data/catalogue/*.yml` and nothing else** (#289). The service reads it at start; the site reads its export. Change it by editing the YAML in a pull request, then run the export. diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 3c61467c..bc76bcc5 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -227,6 +227,11 @@ do_deploy() { fi ok "${env_name} is serving ${sha} on :${web_port} and :${fw_port}" compose ps "$web" "$fw" + # The NFS export of ipctool's builds follows production's image. It holds + # no state and nothing depends on it, so it is started, not gated. + if [ "$env_name" = prod ]; then + compose up -d --no-deps go-nfs || warn "the NFS export (go-nfs) did not start" + fi check_analytics else printf '\033[31m==> health check failed; rolling back\033[0m\n' >&2 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index eb09639d..cb5e215a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -73,6 +73,30 @@ services: - /srv/www/shared/firmware:/srv/firmware - /srv/www/shared/go-release-cache:/srv/release-cache + # ipctool's builds, read-only over NFS, for a camera on stock firmware: + # `mount -o nolock openipc.org:/ipctool /tmp/o` (internal/nfsro). One per + # host -- the portmapper has to be on 111 and NFS on 2049 -- so it runs + # production's image and production's tools directory, and nothing else: + # no database, no settings file. + go-nfs: + <<: *go-common + container_name: openipc-go-nfs + image: ghcr.io/openipc/website-go:${GO_PROD_TAG:-none} + command: ["serve", "--role", "nfs"] + mem_limit: 64m + sysctls: + net.ipv4.ip_unprivileged_port_start: "0" + ports: + - "111:111/udp" + - "111:111/tcp" + - "2049:2049/udp" + - "2049:2049/tcp" + healthcheck: + <<: *go-health + test: ["CMD", "curl", "-fsS", "http://localhost:3004/up"] + volumes: + - /srv/www/shared/tools:/srv/tools:ro + go-web-dev: <<: *go-common container_name: openipc-go-web-dev diff --git a/service/README.md b/service/README.md index ddfc4220..65ee7425 100644 --- a/service/README.md +++ b/service/README.md @@ -29,6 +29,7 @@ key the wall JSON signs them with. | `builds import-history [--keep 90] [--kconfig-all] [--skip-builder]` | once per environment: the builds GitHub still holds, into PostgreSQL | | `boards import-openhisiipcam [--from ]` | once per environment: the OpenHisiIpCam board archive (firmware#659, pinned commit) into the board catalogue, its files under `BOARDS_ROOT`; a second run adds nothing. Run it in the web container, which mounts that directory | | `reports list\|show\|publish\|reject\|link\|unlink\|takedown\|verify` | the owner reports' review queue (`internal/reports`): nothing a camera owner or an agent sends is public until `publish`; `takedown` is the one way a report's content is ever removed; `verify` re-hashes every stored file and runs nightly | +| `serve --role nfs` | ipctool's builds from `TOOLS_ROOT`, read-only over NFS (portmapper :111, MOUNT and NFS :2049, UDP and TCP), for cameras on stock firmware; no database | | `routes --json` | the routes table | | `version` | the commit the binary was built from | diff --git a/service/cmd/openipc/main.go b/service/cmd/openipc/main.go index 0a3f8c3d..bf9908d9 100644 --- a/service/cmd/openipc/main.go +++ b/service/cmd/openipc/main.go @@ -3,6 +3,7 @@ // // openipc serve --role web uploads, variants, the wall, build pushes (:3002) // openipc serve --role firmware full images, their stats, the wizard, availability (:3003) +// openipc serve --role nfs ipctool's builds, read-only over NFS, for stock firmware (:111, :2049) // openipc migrate bring PostgreSQL to this binary's schema // openipc purge [--snapshots] [--firmware] [--builds] nightly retention // openipc probe nightly health numbers, non-zero on trouble @@ -193,9 +194,12 @@ func prefixed(next http.Handler) http.Handler { func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, args []string) error { fs := flag.NewFlagSet("serve", flag.ExitOnError) - role := fs.String("role", "", "web or firmware") + role := fs.String("role", "", "web, firmware or nfs") listen := fs.String("listen", cfg.Listen, "address to listen on") _ = fs.Parse(args) + if *role == "nfs" { + return nfsRole(ctx, cfg, log.With("role", "nfs", "version", version)) + } if *listen == "" { *listen = map[string]string{"web": ":3002", "firmware": ":3003"}[*role] } @@ -237,7 +241,7 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, args []str } background = append(background, stopBg) default: - return fmt.Errorf("--role must be web or firmware") + return fmt.Errorf("--role must be web, firmware or nfs") } srv := &http.Server{ diff --git a/service/cmd/openipc/nfs.go b/service/cmd/openipc/nfs.go new file mode 100644 index 00000000..8ffdc009 --- /dev/null +++ b/service/cmd/openipc/nfs.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "crypto/rand" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "strconv" + "time" + + "github.com/OpenIPC/website/service/internal/config" + "github.com/OpenIPC/website/service/internal/nfsro" +) + +// nfsRole is `openipc serve --role nfs`: TOOLS_ROOT, read-only over NFS, for +// a camera on stock firmware that can mount but cannot fetch +// (`mount -o nolock openipc.org:/ipctool /tmp/o`). No database: it serves the +// files the web role's tools push put there. /up on :3004 for the health +// check. +func nfsRole(ctx context.Context, cfg *config.Config, log *slog.Logger) error { + if _, err := os.Stat(cfg.ToolsRoot); err != nil { + return fmt.Errorf("TOOLS_ROOT: %w", err) + } + _, portStr, err := net.SplitHostPort(cfg.NFSAddr) + if err != nil { + return fmt.Errorf("NFS_ADDR: %w", err) + } + port, _ := strconv.Atoi(portStr) + // Handles are keyed to a secret made at start: a restart makes old + // handles stale, and a camera simply mounts again. + secret := make([]byte, 32) + _, _ = rand.Read(secret) + s := &nfsro.Server{Root: cfg.ToolsRoot, Secret: secret, NFSPort: uint32(port), Log: log} + + errc := make(chan error, 5) + for _, addr := range []string{cfg.PortmapAddr, cfg.NFSAddr} { + pc, err := net.ListenPacket("udp", addr) + if err != nil { + return err + } + l, err := net.Listen("tcp", addr) + if err != nil { + return err + } + go func() { errc <- s.ServeUDP(ctx, pc) }() + go func() { errc <- s.ServeTCP(ctx, l) }() + log.Info("listening", "addr", addr) + } + mux := http.NewServeMux() + mux.HandleFunc("GET /up", func(w http.ResponseWriter, r *http.Request) { + if _, err := os.ReadDir(cfg.ToolsRoot); err != nil { + http.Error(w, "tools directory unreadable", http.StatusServiceUnavailable) + return + } + fmt.Fprintln(w, "ok") + }) + srv := &http.Server{Addr: ":3004", Handler: mux, ReadHeaderTimeout: 5 * time.Second} + go func() { errc <- srv.ListenAndServe() }() + select { + case <-ctx.Done(): + sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(sctx) + return nil + case err := <-errc: + return err + } +} diff --git a/service/deploytest/tools_test.go b/service/deploytest/tools_test.go new file mode 100644 index 00000000..ea29b19f --- /dev/null +++ b/service/deploytest/tools_test.go @@ -0,0 +1,51 @@ +package deploytest + +import ( + "regexp" + "strings" + "testing" +) + +// ipctool's builds reach a camera on stock firmware two ways, both without +// TLS: over plain HTTP for uget, and over NFS for a firmware that can mount. +func TestIpctoolIsServedToStockFirmware(t *testing.T) { + compose := read(t, "deploy/docker-compose.yml") + for _, m := range []string{"- /srv/www/shared/tools:/srv/tools", "- /srv/www/shared/dev-tools:/srv/tools"} { + mustContain(t, compose, m, "the web container does not mount "+m+" for the tools push") + } + nfs := composeService(compose, "go-nfs") + for _, want := range []string{ + `command: ["serve", "--role", "nfs"]`, + "image: ghcr.io/openipc/website-go:${GO_PROD_TAG:-none}", + `- "111:111/udp"`, `- "111:111/tcp"`, `- "2049:2049/udp"`, `- "2049:2049/tcp"`, + "- /srv/www/shared/tools:/srv/tools:ro", + `net.ipv4.ip_unprivileged_port_start: "0"`, + } { + mustContain(t, nfs, want, "go-nfs lacks "+want) + } + mustNotContain(t, nfs, "env_file", "the NFS export has no business with the service's secrets") + mustContain(t, read(t, "deploy/deploy.sh"), "compose up -d --no-deps go-nfs", "deploying production does not start the NFS export") + + for _, v := range []struct{ file, dir string }{ + {"deploy/nginx/sites-available/org.openipc", "/srv/www/shared/tools/"}, + {"deploy/nginx/sites-available/org.openipc.dev", "/srv/www/shared/dev-tools/"}, + } { + loc := block(read(t, v.file), " location ~ ^/(ipctool|ipctool-mips32|ipctool-arm64)$ {") + mustContain(t, loc, "alias "+v.dir+"$1;", v.file+": /ipctool is not the tools directory") + } +} + +// composeService is one service's text in the compose file: from its key to +// the next key at the same indent. +func composeService(compose, name string) string { + i := strings.Index(compose, "\n "+name+":\n") + if i < 0 { + return "" + } + rest := compose[i+1:] + next := regexp.MustCompile(`\n [a-z][a-z0-9-]*:\n`).FindStringIndex(rest[1:]) + if next == nil { + return rest + } + return rest[:next[0]+1] +} diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 3bb1236a..c21b91f4 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -42,6 +42,10 @@ type Config struct { // exports the same directory read-only. ToolsRoot string + // NFS role: the portmapper's and NFS's addresses (UDP and TCP both). + NFSAddr string + PortmapAddr string + // Firmware role. CatalogueDir string ReleaseCacheRoot string // tarballs, keyed by digest @@ -71,6 +75,8 @@ func Load() (*Config, error) { ReportsRoot: str("REPORTS_ROOT", "/srv/owner-reports"), ReportsAccelPrefix: str("REPORTS_ACCEL_PREFIX", "/report-files/"), ToolsRoot: str("TOOLS_ROOT", "/srv/tools"), + NFSAddr: str("NFS_ADDR", ":2049"), + PortmapAddr: str("PORTMAP_ADDR", ":111"), CatalogueDir: str("CATALOGUE_DIR", "/app/catalogue"), ReleaseCacheRoot: str("RELEASE_CACHE_ROOT", "/srv/release-cache"), FirmwareCacheRoot: str("FIRMWARE_CACHE_ROOT", "/srv/firmware"), diff --git a/service/internal/nfsro/fs.go b/service/internal/nfsro/fs.go new file mode 100644 index 00000000..0de4ac96 --- /dev/null +++ b/service/internal/nfsro/fs.go @@ -0,0 +1,140 @@ +package nfsro + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// The export is one flat directory: the files in Root, read at every request +// so a push that replaces one is seen at once. No subdirectories, no +// symlinks, nothing hidden. + +// handleLen is NFSv2's fixed file handle size; v3 uses the same bytes. +const handleLen = 32 + +type node struct { + name string // "" for the directory itself + dir bool + size int64 + mtime time.Time + id uint64 +} + +func (s *Server) list() ([]node, error) { + ents, err := os.ReadDir(s.Root) + if err != nil { + return nil, err + } + var out []node + for _, e := range ents { + if strings.HasPrefix(e.Name(), ".") || !e.Type().IsRegular() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + out = append(out, node{name: e.Name(), size: info.Size(), mtime: info.ModTime(), id: fileID(e.Name())}) + } + sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) + return out, nil +} + +func (s *Server) root() node { + n := node{dir: true, id: 1, mtime: time.Unix(0, 0)} + if info, err := os.Stat(s.Root); err == nil { + n.mtime = info.ModTime() + } + return n +} + +func (s *Server) lookup(name string) (node, bool) { + if name == "" || name == "." || name == ".." { + return s.root(), true + } + if strings.ContainsAny(name, "/\x00") || strings.HasPrefix(name, ".") { + return node{}, false + } + info, err := os.Stat(filepath.Join(s.Root, name)) + if err != nil || !info.Mode().IsRegular() { + return node{}, false + } + return node{name: name, size: info.Size(), mtime: info.ModTime(), id: fileID(name)}, true +} + +func fileID(name string) uint64 { + h := sha256.Sum256([]byte(name)) + return binary.BigEndian.Uint64(h[:8]) | 2 // never the root's 1 +} + +// A file handle is the node's name keyed to the client's address: +// "OIPC" | kind | name length | name (up to 18 bytes) | 8-byte HMAC over +// (secret, client IP, name). A handle works only from the address it was +// given to, so nobody can learn one to aim READ replies over UDP at a spoofed +// victim: the MOUNT answer that carries it goes to the victim, not to them. +func (s *Server) handle(client string, n node) []byte { + h := make([]byte, handleLen) + copy(h, "OIPC") + if n.dir { + h[4] = 1 + } else { + h[4] = 2 + } + name := n.name + if len(name) > 18 { + name = name[:18] + } + h[5] = byte(len(name)) + copy(h[6:24], name) + copy(h[24:], s.mac(client, h[4:24])) + return h +} + +func (s *Server) mac(client string, body []byte) []byte { + m := hmac.New(sha256.New, s.Secret) + m.Write([]byte(client)) + m.Write([]byte{0}) + m.Write(body) + return m.Sum(nil)[:8] +} + +// resolve checks a handle came from this server for this client and names a +// node that still exists. +func (s *Server) resolve(client string, h []byte) (node, uint32) { + if len(h) != handleLen || string(h[:4]) != "OIPC" || h[5] > 18 { + return node{}, errBadHandle + } + if !hmac.Equal(h[24:], s.mac(client, h[4:24])) { + return node{}, errStale + } + switch h[4] { + case 1: + return s.root(), 0 + case 2: + if n, ok := s.lookup(string(h[6 : 6+h[5]])); ok { + return n, 0 + } + } + return node{}, errStale +} + +// Status codes shared by v2 and v3 (RFC 1094, RFC 1813). +const ( + errOK = 0 + errNoEnt = 2 + errIO = 5 + errAccess = 13 + errNotDir = 20 + errIsDir = 21 + errROFS = 30 + errNameLong = 63 + errStale = 70 + errBadHandle = 10001 + errNotSupp = 10004 +) diff --git a/service/internal/nfsro/mount.go b/service/internal/nfsro/mount.go new file mode 100644 index 00000000..e2027a5a --- /dev/null +++ b/service/internal/nfsro/mount.go @@ -0,0 +1,86 @@ +package nfsro + +// The portmapper, v2 (RFC 1833): where MOUNT and NFS are. Both are on one +// port, over UDP and TCP. +func (c *ctx) portmap(proc uint32) (*writer, bool) { + w := &writer{} + switch proc { + case 0: // NULL + case 3: // GETPORT(prog, vers, prot, port) + prog, vers, prot := c.args.u32(), c.args.u32(), c.args.u32() + c.args.u32() + port := uint32(0) + if prot == 6 || prot == 17 { // TCP, UDP + switch { + case prog == progNFS && (vers == 2 || vers == 3), + prog == progMount && (vers == 1 || vers == 2 || vers == 3): + port = c.s.NFSPort + } + } + w.u32(port) + case 4: // DUMP: over TCP only, so it is no amplifier over UDP + if !c.udp { + for _, m := range [][2]uint32{{progNFS, 3}, {progNFS, 2}, {progMount, 3}, {progMount, 1}} { + for _, prot := range []uint32{6, 17} { + w.boolean(true) + w.u32(m[0]) + w.u32(m[1]) + w.u32(prot) + w.u32(c.s.NFSPort) + } + } + } + w.boolean(false) + default: + return nil, false + } + return w, true +} + +// Export is the one path MOUNT answers. "/" is the same directory, for a +// client that asks for the server's root. +const Export = "/ipctool" + +func exported(p string) bool { + switch p { + case Export, Export + "/", "/": + return true + } + return false +} + +// MOUNT v1 and v3 (RFC 1094 appendix A, RFC 1813 appendix I). +func (c *ctx) mount(vers, proc uint32) (*writer, bool) { + w := &writer{} + switch proc { + case 0: // NULL + case 1: // MNT(dirpath) + p := c.args.str(1024) + if !exported(p) { + w.u32(errNoEnt) + return w, true + } + fh := c.s.handle(c.client, c.s.root()) + w.u32(0) + if vers == 1 { + w.fixed(fh) + } else { + w.opaque(fh) + w.u32(2) // auth flavors: AUTH_NONE, AUTH_UNIX + w.u32(0) + w.u32(1) + } + c.s.Log.Info("nfs: mount", "client", c.client, "vers", vers, "udp", c.udp) + case 2: // DUMP: nobody is listed + w.boolean(false) + case 3, 4: // UMNT, UMNTALL + case 5: // EXPORT + w.boolean(true) + w.str(Export) + w.boolean(false) // no groups: everyone + w.boolean(false) + default: + return nil, false + } + return w, true +} diff --git a/service/internal/nfsro/nfs2.go b/service/internal/nfsro/nfs2.go new file mode 100644 index 00000000..95f8493c --- /dev/null +++ b/service/internal/nfsro/nfs2.go @@ -0,0 +1,144 @@ +package nfsro + +// NFS v2 (RFC 1094), read-only: what an old camera kernel mounts with when +// it has no v3. + +func fattr2(w *writer, n node) { + if n.dir { + w.u32(2) // NFDIR + w.u32(0o40555) // mode carries the type bits in v2 + w.u32(2) + } else { + w.u32(1) // NFREG + w.u32(0o100755) + w.u32(1) + } + w.u32(0) // uid + w.u32(0) // gid + size := uint32(n.size) + if n.dir { + size = 4096 + } + w.u32(size) + w.u32(4096) // blocksize + w.u32(0) // rdev + w.u32((size + 511) / 512) // blocks + w.u32(0x4f495043) // fsid + w.u32(uint32(n.id)) + for i := 0; i < 3; i++ { + w.u32(uint32(n.mtime.Unix())) + w.u32(uint32(n.mtime.Nanosecond() / 1000)) + } +} + +// v2 has no BADHANDLE: a handle it cannot use is stale. +func v2status(st uint32) uint32 { + if st == errBadHandle { + return errStale + } + return st +} + +func (c *ctx) nfs2(proc uint32) (*writer, bool) { + w := &writer{} + s := c.s + fh := func() (node, uint32) { + n, st := s.resolve(c.client, c.args.fixed(handleLen)) + return n, v2status(st) + } + switch proc { + case 0, 3: // NULL, ROOT (obsolete, void) + case 1: // GETATTR + n, st := fh() + w.u32(st) + if st == 0 { + fattr2(w, n) + } + case 4: // LOOKUP + dir, st := fh() + name := c.args.str(255) + if st == 0 && !dir.dir { + st = errNotDir + } + if st != 0 { + w.u32(st) + break + } + n, ok := s.lookup(name) + if !ok { + w.u32(errNoEnt) + break + } + w.u32(0) + w.fixed(s.handle(c.client, n)) + fattr2(w, n) + case 5: // READLINK + w.u32(errNotSupp) + case 6: // READ + n, st := fh() + off, count := c.args.u32(), c.args.u32() + c.args.u32() // totalcount + if st == 0 && n.dir { + st = errIsDir + } + if st != 0 { + w.u32(st) + break + } + if count > 8192 { + count = 8192 + } + data, _, err := s.read(n, uint64(off), count) + if err != nil { + w.u32(errIO) + break + } + w.u32(0) + fattr2(w, n) + w.opaque(data) + case 7: // WRITECACHE (obsolete, void) + case 16: // READDIR + dir, st := fh() + cookie := c.args.u32() + c.args.u32() // count + if st == 0 && !dir.dir { + st = errNotDir + } + if st != 0 { + w.u32(st) + break + } + entries, err := s.entries() + if err != nil { + w.u32(errIO) + break + } + w.u32(0) + for i, e := range entries { + if uint32(i) < cookie { + continue + } + w.boolean(true) + w.u32(uint32(e.id)) + w.str(e.label) + w.u32(uint32(i + 1)) + } + w.boolean(false) + w.boolean(true) + case 17: // STATFS + _, st := fh() + w.u32(st) + if st == 0 { + w.u32(8192) // tsize + w.u32(4096) // bsize + w.u32(0) + w.u32(0) + w.u32(0) + } + case 2, 8, 9, 10, 11, 12, 13, 14, 15: // SETATTR WRITE CREATE REMOVE RENAME LINK SYMLINK MKDIR RMDIR + w.u32(errROFS) + default: + return nil, false + } + return w, true +} diff --git a/service/internal/nfsro/nfs3.go b/service/internal/nfsro/nfs3.go new file mode 100644 index 00000000..10d74aca --- /dev/null +++ b/service/internal/nfsro/nfs3.go @@ -0,0 +1,279 @@ +package nfsro + +import ( + "io" + "os" + "path/filepath" +) + +// NFS v3 (RFC 1813), read-only. + +func (c *ctx) rtmax() uint32 { + if c.udp { + return 8192 // one datagram without surprises across the internet + } + return 65536 +} + +func fattr3(w *writer, n node) { + if n.dir { + w.u32(2) // NF3DIR + w.u32(0o555) + w.u32(2) + } else { + w.u32(1) // NF3REG + w.u32(0o755) + w.u32(1) + } + w.u32(0) // uid + w.u32(0) // gid + size := uint64(n.size) + if n.dir { + size = 4096 + } + w.u64(size) + w.u64(size) + w.u32(0) // rdev + w.u32(0) + w.u64(0x4f495043) // fsid "OIPC" + w.u64(n.id) + for i := 0; i < 3; i++ { + w.u32(uint32(n.mtime.Unix())) + w.u32(uint32(n.mtime.Nanosecond())) + } +} + +func postOp(w *writer, n *node) { + if n == nil { + w.boolean(false) + return + } + w.boolean(true) + fattr3(w, *n) +} + +func (c *ctx) nfs3(proc uint32) (*writer, bool) { + w := &writer{} + s := c.s + fh := func() (node, uint32) { return s.resolve(c.client, c.args.opaque(64)) } + switch proc { + case 0: // NULL + case 1: // GETATTR + n, st := fh() + w.u32(st) + if st == 0 { + fattr3(w, n) + } + case 3: // LOOKUP + dir, st := fh() + name := c.args.str(255) + if st != 0 { + w.u32(st) + postOp(w, nil) + break + } + if !dir.dir { + w.u32(errNotDir) + postOp(w, &dir) + break + } + n, ok := s.lookup(name) + if !ok { + w.u32(errNoEnt) + postOp(w, &dir) + break + } + w.u32(0) + w.opaque(s.handle(c.client, n)) + postOp(w, &n) + postOp(w, &dir) + case 4: // ACCESS + n, st := fh() + want := c.args.u32() + w.u32(st) + if st != 0 { + postOp(w, nil) + break + } + postOp(w, &n) + w.u32(want & (0x01 | 0x02 | 0x20)) // READ, LOOKUP, EXECUTE + case 5: // READLINK + _, st := fh() + if st == 0 { + st = errNotSupp + } + w.u32(st) + postOp(w, nil) + case 6: // READ + n, st := fh() + off, count := c.args.u64(), c.args.u32() + if st == 0 && n.dir { + st = errIsDir + } + if st != 0 { + w.u32(st) + postOp(w, nil) + break + } + if count > c.rtmax() { + count = c.rtmax() + } + data, eof, err := s.read(n, off, count) + if err != nil { + w.u32(errIO) + postOp(w, &n) + break + } + w.u32(0) + postOp(w, &n) + w.u32(uint32(len(data))) + w.boolean(eof) + w.opaque(data) + case 16, 17: // READDIR, READDIRPLUS + dir, st := fh() + cookie := c.args.u64() + c.args.fixed(8) // cookieverf + c.args.u32() // count / dircount + if proc == 17 { + c.args.u32() // maxcount + } + if st == 0 && !dir.dir { + st = errNotDir + } + if st != 0 { + w.u32(st) + postOp(w, nil) + break + } + entries, err := s.entries() + if err != nil { + w.u32(errIO) + postOp(w, &dir) + break + } + w.u32(0) + postOp(w, &dir) + w.fixed(make([]byte, 8)) // cookieverf + for i, e := range entries { + if uint64(i) < cookie { + continue + } + w.boolean(true) + w.u64(e.id) + w.str(e.label) + w.u64(uint64(i + 1)) + if proc == 17 { + postOp(w, &e.node) + w.boolean(true) + w.opaque(s.handle(c.client, e.node)) + } + } + w.boolean(false) + w.boolean(true) // eof: the directory is a handful of files + case 18: // FSSTAT + n, st := fh() + w.u32(st) + if st != 0 { + postOp(w, nil) + break + } + postOp(w, &n) + for i := 0; i < 6; i++ { + w.u64(0) + } + w.u32(0) + case 19: // FSINFO + n, st := fh() + w.u32(st) + if st != 0 { + postOp(w, nil) + break + } + postOp(w, &n) + w.u32(c.rtmax()) // rtmax + w.u32(c.rtmax()) // rtpref + w.u32(4096) // rtmult + w.u32(0) // wtmax + w.u32(0) // wtpref + w.u32(4096) // wtmult + w.u32(8192) // dtpref + w.u64(1 << 30) // maxfilesize + w.u32(1) // time_delta + w.u32(0) + w.u32(0x0008) // FSF3_HOMOGENEOUS + case 20: // PATHCONF + n, st := fh() + w.u32(st) + if st != 0 { + postOp(w, nil) + break + } + postOp(w, &n) + w.u32(1) // linkmax + w.u32(255) // name_max + w.boolean(true) + w.boolean(true) + w.boolean(false) + w.boolean(true) + case 2, 7, 8, 9, 10, 11, 12, 13, 21: // SETATTR WRITE CREATE MKDIR SYMLINK MKNOD REMOVE RMDIR COMMIT + w.u32(errROFS) + w.boolean(false) // wcc_data: no pre-op attributes + w.boolean(false) // no post-op attributes + case 14: // RENAME: two wcc_data + w.u32(errROFS) + for i := 0; i < 4; i++ { + w.boolean(false) + } + case 15: // LINK: post_op_attr and wcc_data + w.u32(errROFS) + for i := 0; i < 3; i++ { + w.boolean(false) + } + default: + return nil, false + } + return w, true +} + +type entry struct { + node + label string +} + +// entries is ".", "..", then the files, in the order cookies count them. +func (s *Server) entries() ([]entry, error) { + files, err := s.list() + if err != nil { + return nil, err + } + root := s.root() + out := []entry{{root, "."}, {root, ".."}} + for _, f := range files { + if len(f.name) <= 18 { + out = append(out, entry{f, f.name}) + } + } + return out, nil +} + +func (s *Server) read(n node, off uint64, count uint32) ([]byte, bool, error) { + f, err := os.Open(filepath.Join(s.Root, n.name)) + if err != nil { + return nil, false, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, false, err + } + size := uint64(info.Size()) + if off >= size { + return nil, true, nil + } + buf := make([]byte, count) + k, err := f.ReadAt(buf, int64(off)) + if err != nil && err != io.EOF { + return nil, false, err + } + return buf[:k], off+uint64(k) >= size, nil +} diff --git a/service/internal/nfsro/nfsro_test.go b/service/internal/nfsro/nfsro_test.go new file mode 100644 index 00000000..bba14b66 --- /dev/null +++ b/service/internal/nfsro/nfsro_test.go @@ -0,0 +1,265 @@ +package nfsro + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "log/slog" + "math/rand" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// client speaks just enough ONC RPC to mount and read, as a camera does. +type client struct { + t *testing.T + udp net.Conn + tcp net.Conn + xid uint32 +} + +func (c *client) call(tcp bool, prog, vers, proc uint32, args []byte) *reader { + c.t.Helper() + c.xid++ + w := &writer{} + w.u32(c.xid) + w.u32(0) + w.u32(2) + w.u32(prog) + w.u32(vers) + w.u32(proc) + w.u32(1) // AUTH_UNIX, as busybox sends + w.opaque([]byte("\x00\x00\x00\x00\x00\x00\x00\x04cam\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")) + w.u32(0) + w.u32(0) + msg := append(w.b, args...) + var reply []byte + if tcp { + hdr := binary.BigEndian.AppendUint32(nil, 0x80000000|uint32(len(msg))) + if _, err := c.tcp.Write(append(hdr, msg...)); err != nil { + c.t.Fatal(err) + } + var h [4]byte + if _, err := io.ReadFull(c.tcp, h[:]); err != nil { + c.t.Fatal(err) + } + reply = make([]byte, binary.BigEndian.Uint32(h[:])&0x7fffffff) + if _, err := io.ReadFull(c.tcp, reply); err != nil { + c.t.Fatal(err) + } + } else { + if _, err := c.udp.Write(msg); err != nil { + c.t.Fatal(err) + } + _ = c.udp.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 65536) + n, err := c.udp.Read(buf) + if err != nil { + return nil + } + reply = buf[:n] + } + r := &reader{b: reply} + if r.u32() != c.xid || r.u32() != 1 || r.u32() != 0 { + c.t.Fatalf("not an accepted reply to %d", c.xid) + } + r.u32() + r.opaque(400) + if st := r.u32(); st != 0 { + c.t.Fatalf("prog %d v%d proc %d: accept_stat %d", prog, vers, proc, st) + } + return r +} + +func args(f func(w *writer)) []byte { w := &writer{}; f(w); return w.b } + +func start(t *testing.T, budget int) (*Server, *client, []byte) { + t.Helper() + root := t.TempDir() + big := make([]byte, 200_000) // about a packed ipctool + rand.New(rand.NewSource(1)).Read(big) + for name, data := range map[string][]byte{"ipctool": big, "ipctool-mips32": []byte("mips"), ".ipctool.tmp": []byte("half")} { + if err := os.WriteFile(filepath.Join(root, name), data, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil { + t.Fatal(err) + } + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + s := &Server{Root: root, Secret: []byte("test"), NFSPort: 2049, Budget: budget, + Log: slog.New(slog.NewTextHandler(io.Discard, nil))} + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go s.ServeUDP(ctx, pc) + go s.ServeTCP(ctx, l) + u, _ := net.Dial("udp", pc.LocalAddr().String()) + tc, _ := net.Dial("tcp", l.Addr().String()) + t.Cleanup(func() { u.Close(); tc.Close() }) + return s, &client{t: t, udp: u, tcp: tc}, big +} + +// What busybox does for `mount -o nolock host:/ipctool /x` then `cat /x/ipctool` +// on a v3 kernel: portmapper, MOUNT v3, LOOKUP, READ to the end. +func TestAStockCameraMountsAndReadsIpctool(t *testing.T) { + for _, tcp := range []bool{false, true} { + _, c, big := start(t, 0) + r := c.call(tcp, progPortmap, 2, 3, args(func(w *writer) { w.u32(progMount); w.u32(3); w.u32(17); w.u32(0) })) + if p := r.u32(); p != 2049 { + t.Fatalf("portmapper says MOUNT is on %d", p) + } + r = c.call(tcp, progMount, 3, 1, args(func(w *writer) { w.str("/ipctool") })) + if st := r.u32(); st != 0 { + t.Fatalf("MNT: %d", st) + } + root := r.opaque(64) + r = c.call(tcp, progNFS, 3, 3, args(func(w *writer) { w.opaque(root); w.str("ipctool") })) + if st := r.u32(); st != 0 { + t.Fatalf("LOOKUP: %d", st) + } + fh := r.opaque(64) + var got []byte + for off := uint64(0); ; { + r = c.call(tcp, progNFS, 3, 6, args(func(w *writer) { w.opaque(fh); w.u64(off); w.u32(32768) })) + if st := r.u32(); st != 0 { + t.Fatalf("READ at %d: %d", off, st) + } + if r.u32() == 1 { + r.fixed(84) + } + n := r.u32() + eof := r.u32() == 1 + data := r.opaque(1 << 20) + if uint32(len(data)) != n || (!tcp && n > 8192) { + t.Fatalf("READ gave %d bytes, said %d (udp=%v)", len(data), n, !tcp) + } + got = append(got, data...) + off += uint64(n) + if eof { + break + } + } + if !bytes.Equal(got, big) { + t.Errorf("read %d bytes, not the file (tcp=%v)", len(got), tcp) + } + // the listing is the two builds, not the half-written temp or the directory + r = c.call(tcp, progNFS, 3, 16, args(func(w *writer) { w.opaque(root); w.u64(0); w.fixed(make([]byte, 8)); w.u32(4096) })) + if st := r.u32(); st != 0 { + t.Fatalf("READDIR: %d", st) + } + if r.u32() == 1 { + r.fixed(84) + } + r.fixed(8) + var names []string + for r.u32() == 1 { + r.u64() + names = append(names, r.str(255)) + r.u64() + } + if strings.Join(names, " ") != ". .. ipctool ipctool-mips32" { + t.Errorf("READDIR: %v", names) + } + // nothing can be written + r = c.call(tcp, progNFS, 3, 7, args(func(w *writer) { w.opaque(fh); w.u64(0); w.u32(1); w.u32(2); w.opaque([]byte("x")) })) + if st := r.u32(); st != errROFS { + t.Errorf("WRITE: %d, want ROFS", st) + } + r = c.call(tcp, progMount, 3, 1, args(func(w *writer) { w.str("/etc") })) + if st := r.u32(); st != errNoEnt { + t.Errorf("MNT /etc: %d", st) + } + } +} + +// An older kernel mounts with v1 and reads with NFS v2. +func TestAV2KernelMountsAndReads(t *testing.T) { + _, c, big := start(t, 0) + r := c.call(false, progMount, 1, 1, args(func(w *writer) { w.str("/ipctool") })) + if st := r.u32(); st != 0 { + t.Fatalf("MNT v1: %d", st) + } + root := r.fixed(handleLen) + r = c.call(false, progNFS, 2, 4, args(func(w *writer) { w.fixed(root); w.str("ipctool") })) + if st := r.u32(); st != 0 { + t.Fatalf("LOOKUP v2: %d", st) + } + fh := r.fixed(handleLen) + r.fixed(68) + var got []byte + for off := uint32(0); off < uint32(len(big)); { + r = c.call(false, progNFS, 2, 6, args(func(w *writer) { w.fixed(fh); w.u32(off); w.u32(8192); w.u32(0) })) + if st := r.u32(); st != 0 { + t.Fatalf("READ v2: %d", st) + } + r.fixed(68) + data := r.opaque(8192) + if len(data) == 0 { + break + } + got = append(got, data...) + off += uint32(len(data)) + } + if !bytes.Equal(got, big) { + t.Errorf("v2 read %d bytes, not the file", len(got)) + } +} + +// A handle works only from the address it was issued to: a spoofer cannot +// aim READ answers at somebody else. +func TestAHandleIsStaleFromAnotherAddress(t *testing.T) { + s, _, _ := start(t, 0) + n, _ := s.lookup("ipctool") + fh := s.handle("198.51.100.7", n) + if _, st := s.resolve("198.51.100.7", fh); st != 0 { + t.Fatalf("own address: %d", st) + } + if _, st := s.resolve("203.0.113.9", fh); st != errStale { + t.Errorf("another address: %d, want STALE", st) + } + fh[10] ^= 1 + if _, st := s.resolve("198.51.100.7", fh); st != errStale { + t.Errorf("a forged handle: %d", st) + } +} + +// Over UDP one address gets at most its budget a minute; then silence. +func TestUDPAnswersStopAtTheBudget(t *testing.T) { + _, c, _ := start(t, 1000) + answered := 0 + for i := 0; i < 50; i++ { + c.xid++ + w := &writer{} + w.u32(c.xid) + w.u32(0) + w.u32(2) + w.u32(progMount) + w.u32(3) + w.u32(5) // EXPORT + w.u32(0) + w.u32(0) + w.u32(0) + w.u32(0) + c.udp.Write(w.b) + _ = c.udp.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + buf := make([]byte, 1024) + if _, err := c.udp.Read(buf); err == nil { + answered++ + } + } + if answered == 0 || answered == 50 { + t.Errorf("%d of 50 answered with a 1000-byte budget", answered) + } +} diff --git a/service/internal/nfsro/server.go b/service/internal/nfsro/server.go new file mode 100644 index 00000000..3a6a1a62 --- /dev/null +++ b/service/internal/nfsro/server.go @@ -0,0 +1,253 @@ +// Package nfsro is a read-only NFS server for one flat directory: ipctool's +// builds, for a camera whose stock firmware can mount NFS but has no curl, +// no wget and no TLS (the `openipc serve --role nfs` process). +// +// What a stock camera runs is busybox's `mount -o nolock host:/ipctool /x` +// with no other option. That asks the portmapper on port 111 over UDP where +// MOUNT lives, then speaks MOUNT and NFS over UDP, in whichever of v2 and v3 +// the kernel has. So this answers all of it, over UDP and TCP: the portmapper +// (v2), MOUNT v1 and v3, and NFS v2 and v3, with everything that would write +// refused as a read-only file system. MOUNT and NFS share one port. +// +// NFS READ over UDP is a known reflection amplifier: a spoofed 100-byte +// request earns an 8 KB answer sent to the victim. Here a file handle is +// keyed to the address it was issued to (fs.go), so a spoofer cannot hold a +// handle that works from the victim's address; every UDP answer is also +// counted against a per-address byte budget. +package nfsro + +import ( + "context" + "encoding/binary" + "errors" + "io" + "log/slog" + "net" + "sync" + "time" +) + +type Server struct { + Root string + Secret []byte + NFSPort uint32 // what the portmapper answers for MOUNT and NFS + Log *slog.Logger + // Budget is how many bytes one address may be sent over UDP per minute. + // A mount and a read of ipctool is ~250 KB. + Budget int + + mu sync.Mutex + spent map[string]*spend +} + +type spend struct { + since time.Time + bytes int +} + +// charge says whether n more bytes may go to addr over UDP this minute. +func (s *Server) charge(addr string, n int) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.spent == nil { + s.spent = map[string]*spend{} + } + now := time.Now() + sp := s.spent[addr] + if sp == nil || now.Sub(sp.since) > time.Minute { + if len(s.spent) > 100000 { + s.spent = map[string]*spend{} + } + sp = &spend{since: now} + s.spent[addr] = sp + } + budget := s.Budget + if budget == 0 { + budget = 4 << 20 + } + if sp.bytes+n > budget { + return false + } + sp.bytes += n + return true +} + +// ServeUDP answers datagrams on conn until ctx ends. +func (s *Server) ServeUDP(ctx context.Context, conn net.PacketConn) error { + go func() { <-ctx.Done(); conn.Close() }() + buf := make([]byte, 65536) + for { + n, addr, err := conn.ReadFrom(buf) + if err != nil { + if ctx.Err() != nil { + return nil + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + continue + } + return err + } + host := hostOf(addr) + reply := s.call(host, buf[:n], true) + if reply == nil || !s.charge(host, len(reply)) { + continue + } + _, _ = conn.WriteTo(reply, addr) + } +} + +// ServeTCP answers record-marked calls (RFC 5531 §11) on every connection. +func (s *Server) ServeTCP(ctx context.Context, l net.Listener) error { + go func() { <-ctx.Done(); l.Close() }() + for { + c, err := l.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + go s.conn(c) + } +} + +const maxRecord = 1 << 20 + +func (s *Server) conn(c net.Conn) { + defer c.Close() + host := hostOf(c.RemoteAddr()) + for { + _ = c.SetReadDeadline(time.Now().Add(5 * time.Minute)) + var rec []byte + for { + var hdr [4]byte + if _, err := io.ReadFull(c, hdr[:]); err != nil { + return + } + v := binary.BigEndian.Uint32(hdr[:]) + n := int(v & 0x7fffffff) + if len(rec)+n > maxRecord { + return + } + frag := make([]byte, n) + if _, err := io.ReadFull(c, frag); err != nil { + return + } + rec = append(rec, frag...) + if v&0x80000000 != 0 { + break + } + } + reply := s.call(host, rec, false) + if reply == nil { + continue + } + out := binary.BigEndian.AppendUint32(nil, 0x80000000|uint32(len(reply))) + if _, err := c.Write(append(out, reply...)); err != nil { + return + } + } +} + +func hostOf(a net.Addr) string { + switch v := a.(type) { + case *net.UDPAddr: + return v.IP.String() + case *net.TCPAddr: + return v.IP.String() + } + h, _, _ := net.SplitHostPort(a.String()) + return h +} + +// ONC RPC (RFC 5531). +const ( + progPortmap = 100000 + progNFS = 100003 + progMount = 100005 + + acceptSuccess = 0 + acceptProgUna = 1 + acceptProgMism = 2 + acceptProcUna = 3 + acceptGarbage = 4 +) + +// call decodes one RPC call and returns the whole reply, or nil to drop it. +func (s *Server) call(client string, msg []byte, udp bool) []byte { + r := &reader{b: msg} + xid := r.u32() + if r.u32() != 0 { // CALL + return nil + } + if r.u32() != 2 { // RPC version + return nil + } + prog, vers, proc := r.u32(), r.u32(), r.u32() + r.u32() // cred flavor + r.opaque(400) // cred body + r.u32() // verf flavor + r.opaque(400) // verf body + if r.err != nil { + return nil + } + w := &writer{} + w.u32(xid) + w.u32(1) // REPLY + w.u32(0) // MSG_ACCEPTED + w.u32(0) // verf AUTH_NONE + w.u32(0) + mismatch := func(lo, hi uint32) []byte { + w.u32(acceptProgMism) + w.u32(lo) + w.u32(hi) + return w.b + } + c := &ctx{s: s, client: client, udp: udp, args: r} + var res *writer + var ok bool + switch prog { + case progPortmap: + if vers != 2 { + return mismatch(2, 2) + } + res, ok = c.portmap(proc) + case progMount: + if vers != 1 && vers != 3 { + return mismatch(1, 3) + } + res, ok = c.mount(vers, proc) + case progNFS: + switch vers { + case 2: + res, ok = c.nfs2(proc) + case 3: + res, ok = c.nfs3(proc) + default: + return mismatch(2, 3) + } + default: + w.u32(acceptProgUna) + return w.b + } + if !ok { + w.u32(acceptProcUna) + return w.b + } + if r.err != nil { + w.u32(acceptGarbage) + return w.b + } + w.u32(acceptSuccess) + w.b = append(w.b, res.b...) + return w.b +} + +// ctx is one call being answered. +type ctx struct { + s *Server + client string + udp bool + args *reader +} diff --git a/service/internal/nfsro/xdr.go b/service/internal/nfsro/xdr.go new file mode 100644 index 00000000..0b85f6d5 --- /dev/null +++ b/service/internal/nfsro/xdr.go @@ -0,0 +1,76 @@ +package nfsro + +import ( + "encoding/binary" + "errors" +) + +// XDR (RFC 4506): big-endian, everything padded to four bytes. Only what +// ONC RPC, the portmapper, MOUNT and NFS v2/v3 need. + +var errShort = errors.New("xdr: short") + +type reader struct { + b []byte + err error +} + +func (r *reader) u32() uint32 { + if r.err != nil || len(r.b) < 4 { + r.err = errShort + return 0 + } + v := binary.BigEndian.Uint32(r.b) + r.b = r.b[4:] + return v +} + +func (r *reader) u64() uint64 { return uint64(r.u32())<<32 | uint64(r.u32()) } + +func (r *reader) fixed(n int) []byte { + p := (n + 3) &^ 3 + if r.err != nil || len(r.b) < p { + r.err = errShort + return nil + } + v := r.b[:n] + r.b = r.b[p:] + return v +} + +func (r *reader) opaque(max int) []byte { + n := int(r.u32()) + if r.err == nil && (n > max || n < 0) { + r.err = errShort + return nil + } + return r.fixed(n) +} + +func (r *reader) str(max int) string { return string(r.opaque(max)) } + +type writer struct{ b []byte } + +func (w *writer) u32(v uint32) { w.b = binary.BigEndian.AppendUint32(w.b, v) } +func (w *writer) u64(v uint64) { w.b = binary.BigEndian.AppendUint64(w.b, v) } +func (w *writer) boolean(v bool) { + if v { + w.u32(1) + } else { + w.u32(0) + } +} + +func (w *writer) fixed(v []byte) { + w.b = append(w.b, v...) + for len(w.b)%4 != 0 { + w.b = append(w.b, 0) + } +} + +func (w *writer) opaque(v []byte) { + w.u32(uint32(len(v))) + w.fixed(v) +} + +func (w *writer) str(v string) { w.opaque([]byte(v)) } From c684be5e6a16b45657ba2c4580da8ddd477a658d Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 29 Sep 2026 12:47:15 +0000 Subject: [PATCH 07/10] Owner reports on the site: /cameras/report, a receipt, and reports on a board - /cameras/report (en, ru, zh): how a new board reaches the catalogue, from a shell on the camera or from an AI coding agent. The shell route never says curl, because stock firmware has none. uget is pasted in as text (its six release scripts are vendored under /uget/ and picked by the C library ls /lib/ld-* shows), then fetches http://openipc.org/ipctool; an NFS mount is the alternative. - With ?id= the page is the receipt ipctool prints: state, what arrived, who may see each file, and the catalogue board the report most likely is. - A board's panel lists its published reports (GET /api/v1/reports?model=), with identifiers already hashed and a private backup shown only as existing. - The featured-hardware strip's "send us the report" goes here, not to /community. --- data/locales/boards.en.yml | 70 +++++++++ data/locales/boards.ru.yml | 70 +++++++++ data/locales/boards.zh.yml | 70 +++++++++ data/locales/ways.en.yml | 2 +- data/locales/ways.ru.yml | 2 +- data/locales/ways.zh.yml | 2 +- .../public/uget/uget.arm-himix100-linux.sh | 26 ++++ .../public/uget/uget.arm-himix200-linux.sh | 38 +++++ .../public/uget/uget.arm-hisiv300-linux.sh | 26 ++++ .../public/uget/uget.arm-hisiv500-linux.sh | 26 ++++ .../public/uget/uget.arm-hisiv510-linux.sh | 26 ++++ .../public/uget/uget.arm-hisiv600-linux.sh | 27 ++++ .../site/src/components/boards/BoardPanel.tsx | 3 + .../site/src/components/pages/Report.astro | 86 +++++++++++ .../src/components/reports/OwnerReports.tsx | 75 +++++++++ .../site/src/components/reports/Receipt.tsx | 142 ++++++++++++++++++ .../site/src/components/reports/UgetCopy.tsx | 55 +++++++ frontend/apps/site/src/i18n/boards.en.json | 45 ++++++ frontend/apps/site/src/i18n/boards.ru.json | 45 ++++++ frontend/apps/site/src/i18n/boards.zh.json | 45 ++++++ frontend/apps/site/src/i18n/en.json | 29 +++- frontend/apps/site/src/i18n/ru.json | 29 +++- frontend/apps/site/src/i18n/zh.json | 29 +++- frontend/apps/site/src/lib/page-paths.ts | 2 + frontend/apps/site/src/lib/pages.ts | 2 + frontend/apps/site/src/lib/reports.ts | 84 +++++++++++ service/cmd/openipc/main.go | 1 + service/internal/reports/api_test.go | 29 ++++ service/internal/reports/handler.go | 19 +++ service/internal/reports/view.go | 34 +++++ service/routes.json | 5 + 31 files changed, 1138 insertions(+), 6 deletions(-) create mode 100644 frontend/apps/site/public/uget/uget.arm-himix100-linux.sh create mode 100644 frontend/apps/site/public/uget/uget.arm-himix200-linux.sh create mode 100644 frontend/apps/site/public/uget/uget.arm-hisiv300-linux.sh create mode 100644 frontend/apps/site/public/uget/uget.arm-hisiv500-linux.sh create mode 100644 frontend/apps/site/public/uget/uget.arm-hisiv510-linux.sh create mode 100644 frontend/apps/site/public/uget/uget.arm-hisiv600-linux.sh create mode 100644 frontend/apps/site/src/components/pages/Report.astro create mode 100644 frontend/apps/site/src/components/reports/OwnerReports.tsx create mode 100644 frontend/apps/site/src/components/reports/Receipt.tsx create mode 100644 frontend/apps/site/src/components/reports/UgetCopy.tsx create mode 100644 frontend/apps/site/src/lib/reports.ts diff --git a/data/locales/boards.en.yml b/data/locales/boards.en.yml index b2a1bfe6..dc30bf54 100644 --- a/data/locales/boards.en.yml +++ b/data/locales/boards.en.yml @@ -4,6 +4,32 @@ en: title: Camera boards heading: Your board is probably here lede: 'Camera and recorder boards with photos, UART pinouts, specifications and, where we have them, factory flash dumps and U-Boot consoles. Match the PCB in your hand, then install OpenIPC on its SoC.' + report: + title: "Send us a board" + eyebrow: "Board catalogue" + lede: "A camera nobody has reported yet reaches the catalogue from one report: what ipctool reads off the board and, if you want to help port OpenIPC to it, the flash it came with." + shell_title: "You have a shell on the camera" + shell_lede_html: "Telnet into the stock firmware, or a UART console. Stock firmware has no curl, so ipctool comes over plain HTTP from openipc.org, fetched by uget: a 5 KB downloader you paste in as text." + step_uget: "1. Paste uget into the telnet session" + step_fetch: "2. Fetch ipctool and send the report" + nfs_title: "The firmware has an NFS client (most Xiongmai do)" + nfs_hint_html: "Other ways in, over UART, TFTP or an SD card: ipctool's README." + flag_upload: "the hardware report: SoC, sensor, flash layout, firmware versions" + flag_backup: "adds the whole flash, kept private for OpenIPC's maintainers. ipctool asks you to type yes first." + flag_public: "the backup is published with the report, once reviewed" + flag_note: "where you bought it and what it is sold as" + agent_title: "An AI coding agent is doing the digging" + agent_lede: "Give it this page. It checks the catalogue first, then sends what it found as a report of its own." + agent_prompt: "Read https://openipc.org/agents.md and follow it to identify this camera and send openipc.org a report." + agent_step1_html: "Ask POST /api/v1/boards/identify with ipctool's output: if the board is known, stop there, with its firmware." + agent_step2: "Collect the boot log, the U-Boot environment and, with the owner's say-so, a flash read." + agent_step3_html: "Send it all to POST /api/v1/reports and pass the receipt back to you." + fact1_title: "Read before it is shown" + fact1: "OpenIPC's maintainers review each report and file it under its board. Until then, only you can see it, at its receipt." + fact2_title: "Your camera stays anonymous" + fact2: "The MAC, the chip's serial and the cloud ID are replaced with hashes in everything published, in the report and in any log." + fact3_title: "A backup is private unless you say otherwise" + fact3_html: "It holds everything the camera stores, including Wi-Fi keys and passwords. It is published only if you sent it with --public." nav: boards: Camera boards cameras: @@ -11,6 +37,50 @@ en: index: boards: Find by board or camera boards: + report: + libc_uclibc: "ls /lib/ld-* shows ld-uClibc.so.0" + libc_glibc: "ls /lib/ld-* shows ld-linux.so.3" + uget_hint_html: "uget is linked against the camera's own C library: run ls /lib/ld-* first and pick what it shows. The text rebuilds itself as /tmp/uget. If the camera says not found or crashes, try the next build." + uget_build: "Build" + copy_uget: "Copy uget as text" + copied: "Copied: paste it into telnet" + copy_failed: "Could not copy. Open the text and copy it by hand:" + lookup_label: "Have a receipt?" + lookup_button: "Show its state" + status_pending: "Waiting for review" + status_published: "Published" + status_rejected: "Not published" + status_withdrawn: "Withdrawn" + backup_private: "Backup: private" + backup_public: "Backup: shared" + received: "Received from {channel}" + step_review: "A maintainer reads it and files it under its board" + step_review_hint: "Usually within a few days. This page changes when that happens." + step_published: "Published on the board's page" + step_published_hint: "Only the anonymised copy. A private backup stays private." + th_what: "What arrived" + th_size: "Size" + th_who: "Who can see it" + who_public: "Everyone, once published, with identifiers hashed" + who_private: "OpenIPC's maintainers only" + kind_report: "ipctool report" + kind_backup: "Flash backup" + kind_photo: "Photo" + kind_boot_log: "Boot log" + kind_uboot_env: "U-Boot environment" + kind_note: "Note" + kind_document: "Document" + looks_like: "Looks like {board} in the catalogue" + looks_like_why: "not certain until reviewed" + filed_under: "Filed under" + not_found: "No report has this receipt. Check the address ipctool printed." + load_failed: "The report could not be loaded. Try again in a minute." + reports_title: "Reports from owners" + reports_same_board: "same board as another report" + reports_yaml: "ipctool's output" + reports_ask: "Have this board?" + reports_send: "Send us its report" + reports_ask_tail: "Two owners with the same board is how a variant gets noticed." stats_boards: one: board other: boards diff --git a/data/locales/boards.ru.yml b/data/locales/boards.ru.yml index 66a826e8..6c7c8f27 100644 --- a/data/locales/boards.ru.yml +++ b/data/locales/boards.ru.yml @@ -4,6 +4,32 @@ ru: title: Платы камер heading: Ваша плата, скорее всего, уже здесь lede: 'Платы камер и регистраторов: фотографии, распиновка UART, характеристики, а где они есть — заводские дампы флеш-памяти и консоль U-Boot. Найдите плату, которая у вас в руках, и установите OpenIPC на её SoC.' + report: + title: "Прислать плату" + eyebrow: "Каталог плат" + lede: "Камера, о которой ещё никто не сообщил, попадает в каталог по одному отчёту: что ipctool прочитал с платы, а если хотите помочь с портированием OpenIPC — ещё и заводская прошивка с флеша." + shell_title: "У вас есть shell на камере" + shell_lede_html: "Telnet в заводскую прошивку или консоль UART. В заводских прошивках нет curl, поэтому ipctool скачивается с openipc.org по обычному HTTP утилитой uget: это загрузчик на 5 КБ, который вставляется в сессию как текст." + step_uget: "1. Вставьте uget в telnet-сессию" + step_fetch: "2. Скачайте ipctool и отправьте отчёт" + nfs_title: "В прошивке есть NFS-клиент (у большинства Xiongmai есть)" + nfs_hint_html: "Другие способы — через UART, TFTP или SD-карту: README ipctool." + flag_upload: "отчёт о железе: SoC, сенсор, разметка флеша, версии прошивки" + flag_backup: "добавляет весь флеш, доступный только мейнтейнерам OpenIPC. Сначала ipctool попросит ввести yes." + flag_public: "бэкап публикуется вместе с отчётом после проверки" + flag_note: "где куплена камера и под каким названием продаётся" + agent_title: "Разбирается ИИ-агент для программирования" + agent_lede: "Дайте ему эту страницу. Он сначала проверит каталог, а потом пришлёт найденное отдельным отчётом." + agent_prompt: "Read https://openipc.org/agents.md and follow it to identify this camera and send openipc.org a report." + agent_step1_html: "Спросить POST /api/v1/boards/identify с выводом ipctool: если плата известна — остановиться и взять её прошивку." + agent_step2: "Собрать лог загрузки, окружение U-Boot и, с согласия владельца, чтение флеша." + agent_step3_html: "Отправить всё в POST /api/v1/reports и вернуть вам квитанцию." + fact1_title: "Проверяется до публикации" + fact1: "Мейнтейнеры OpenIPC просматривают каждый отчёт и привязывают его к плате. До этого он виден только вам — по квитанции." + fact2_title: "Ваша камера остаётся анонимной" + fact2: "MAC, серийный номер чипа и облачный ID во всём опубликованном заменены хешами — и в отчёте, и в логах." + fact3_title: "Бэкап закрыт, если вы не решите иначе" + fact3_html: "В нём всё, что хранит камера, включая ключи Wi-Fi и пароли. Он публикуется, только если вы отправили его с --public." nav: boards: Платы камер cameras: @@ -11,6 +37,50 @@ ru: index: boards: Поиск по плате или камере boards: + report: + libc_uclibc: "ls /lib/ld-* показывает ld-uClibc.so.0" + libc_glibc: "ls /lib/ld-* показывает ld-linux.so.3" + uget_hint_html: "uget собран под C-библиотеку самой камеры: сначала выполните ls /lib/ld-* и выберите то, что она показывает. Текст сам соберётся в /tmp/uget. Если камера ответит not found или упадёт — попробуйте следующую сборку." + uget_build: "Сборка" + copy_uget: "Скопировать uget как текст" + copied: "Скопировано: вставьте в telnet" + copy_failed: "Не удалось скопировать. Откройте текст и скопируйте вручную:" + lookup_label: "Есть квитанция?" + lookup_button: "Показать состояние" + status_pending: "Ждёт проверки" + status_published: "Опубликован" + status_rejected: "Не опубликован" + status_withdrawn: "Отозван" + backup_private: "Бэкап: закрытый" + backup_public: "Бэкап: открытый" + received: "Получен от {channel}" + step_review: "Мейнтейнер читает отчёт и привязывает его к плате" + step_review_hint: "Обычно за несколько дней. Эта страница изменится, когда это случится." + step_published: "Опубликован на странице платы" + step_published_hint: "Только анонимизированная копия. Закрытый бэкап остаётся закрытым." + th_what: "Что пришло" + th_size: "Размер" + th_who: "Кому видно" + who_public: "Всем после публикации, идентификаторы заменены хешами" + who_private: "Только мейнтейнерам OpenIPC" + kind_report: "Отчёт ipctool" + kind_backup: "Бэкап флеша" + kind_photo: "Фото" + kind_boot_log: "Лог загрузки" + kind_uboot_env: "Окружение U-Boot" + kind_note: "Заметка" + kind_document: "Документ" + looks_like: "Похоже на {board} из каталога" + looks_like_why: "точно станет ясно после проверки" + filed_under: "Привязан к" + not_found: "Отчёта с такой квитанцией нет. Проверьте адрес, который напечатал ipctool." + load_failed: "Не удалось загрузить отчёт. Попробуйте через минуту." + reports_title: "Отчёты владельцев" + reports_same_board: "та же плата, что в другом отчёте" + reports_yaml: "Вывод ipctool" + reports_ask: "Есть такая плата?" + reports_send: "Пришлите её отчёт" + reports_ask_tail: "Когда у двух владельцев одна плата, так и замечают новые варианты." stats_boards: one: плата few: платы diff --git a/data/locales/boards.zh.yml b/data/locales/boards.zh.yml index 8b57d658..0cbdea17 100644 --- a/data/locales/boards.zh.yml +++ b/data/locales/boards.zh.yml @@ -4,6 +4,32 @@ zh: title: 摄像头主板 heading: 你的主板很可能就在这里 lede: '摄像机和录像机主板:照片、UART 引脚图、规格参数,以及(如有)出厂闪存转储和 U-Boot 控制台输出。先找到与你手中 PCB 相同的主板,再在它的 SoC 上安装 OpenIPC。' + report: + title: "提交电路板" + eyebrow: "电路板目录" + lede: "一台还没人报告过的摄像头,只需一份报告就能进入目录:ipctool 从板子上读到的信息;如果您愿意帮助移植 OpenIPC,再附上出厂闪存。" + shell_title: "您能进入摄像头的 shell" + shell_lede_html: "通过 telnet 进入原厂固件,或使用 UART 控制台。原厂固件没有 curl,所以 ipctool 由 uget 通过普通 HTTP 从 openipc.org 下载:uget 是一个 5 KB 的下载器,以文本形式粘贴进去即可。" + step_uget: "1. 把 uget 粘贴到 telnet 会话中" + step_fetch: "2. 下载 ipctool 并发送报告" + nfs_title: "固件带 NFS 客户端(大多数雄迈固件都有)" + nfs_hint_html: "其他途径(UART、TFTP 或 SD 卡):见 ipctool 的 README。" + flag_upload: "硬件报告:SoC、传感器、闪存分区、固件版本" + flag_backup: "附带整个闪存,仅 OpenIPC 维护者可见。ipctool 会先要求您输入 yes。" + flag_public: "审核后,备份随报告一起公开" + flag_note: "购买渠道和销售名称" + agent_title: "由 AI 编程代理来分析" + agent_lede: "把这个页面交给它。它会先查目录,再把发现的内容作为单独的报告发送。" + agent_prompt: "Read https://openipc.org/agents.md and follow it to identify this camera and send openipc.org a report." + agent_step1_html: "用 ipctool 的输出请求 POST /api/v1/boards/identify:如果电路板已知,就到此为止,直接使用它的固件。" + agent_step2: "收集启动日志、U-Boot 环境变量,并在所有者同意后读取闪存。" + agent_step3_html: "全部发送到 POST /api/v1/reports,并把回执交给您。" + fact1_title: "先审核,后公开" + fact1: "OpenIPC 维护者会审核每份报告并归到对应的电路板下。在此之前,只有您能通过回执查看。" + fact2_title: "您的摄像头保持匿名" + fact2: "在所有公开内容中(报告和日志),MAC、芯片序列号和云 ID 都会被替换为哈希值。" + fact3_title: "备份默认不公开" + fact3_html: "备份包含摄像头存储的一切,包括 Wi-Fi 密钥和密码。只有使用 --public 发送时才会公开。" nav: boards: 摄像头主板 cameras: @@ -11,6 +37,50 @@ zh: index: boards: 按主板或摄像机查找 boards: + report: + libc_uclibc: "ls /lib/ld-* 显示 ld-uClibc.so.0" + libc_glibc: "ls /lib/ld-* 显示 ld-linux.so.3" + uget_hint_html: "uget 链接的是摄像头自己的 C 库:先运行 ls /lib/ld-*,按显示结果选择。文本会自行还原为 /tmp/uget。如果摄像头提示 not found 或崩溃,请换下一个版本。" + uget_build: "版本" + copy_uget: "以文本形式复制 uget" + copied: "已复制:粘贴到 telnet 中" + copy_failed: "无法复制。请打开文本手动复制:" + lookup_label: "有回执?" + lookup_button: "查看状态" + status_pending: "等待审核" + status_published: "已发布" + status_rejected: "未发布" + status_withdrawn: "已撤回" + backup_private: "备份:不公开" + backup_public: "备份:公开" + received: "来自 {channel}" + step_review: "维护者阅读报告并归到对应电路板下" + step_review_hint: "通常几天之内。完成后此页面会更新。" + step_published: "发布在电路板页面" + step_published_hint: "仅发布匿名副本。不公开的备份保持不公开。" + th_what: "收到的内容" + th_size: "大小" + th_who: "谁能看到" + who_public: "发布后所有人可见,标识符已替换为哈希" + who_private: "仅 OpenIPC 维护者" + kind_report: "ipctool 报告" + kind_backup: "闪存备份" + kind_photo: "照片" + kind_boot_log: "启动日志" + kind_uboot_env: "U-Boot 环境变量" + kind_note: "备注" + kind_document: "文档" + looks_like: "看起来像目录中的 {board}" + looks_like_why: "审核后才能确定" + filed_under: "归属" + not_found: "没有与此回执对应的报告。请检查 ipctool 打印的地址。" + load_failed: "无法加载报告。请稍后再试。" + reports_title: "用户报告" + reports_same_board: "与另一份报告是同一块板" + reports_yaml: "ipctool 的输出" + reports_ask: "有这块板?" + reports_send: "发送它的报告" + reports_ask_tail: "两位用户拥有同一块板,新的变体就是这样被发现的。" stats_boards: one: 块主板 other: 块主板 diff --git a/data/locales/ways.en.yml b/data/locales/ways.en.yml index a20413ab..a118894e 100644 --- a/data/locales/ways.en.yml +++ b/data/locales/ways.en.yml @@ -19,7 +19,7 @@ en: buy_text: "These manufacturers and integrators ship cameras with OpenIPC on them. Plug one in and open its web interface." buy_title: "Buy one with OpenIPC already installed" buy_when: "Easiest · no tools" - experts_html: "Reverse engineering a new camera? Get a shell on it and run ipctool, or have an AI coding agent dig through the vendor firmware, then send us the report. That is how new boards reach this list." + experts_html: "Reverse engineering a new camera? Get a shell on it and run ipctool, or have an AI coding agent dig through the vendor firmware, then send us the report. That is how new boards reach this list." home_all: "All supported hardware" home_lede: "You don’t need to know what a SoC is, and none of these needs a soldering iron." home_title: "Four ways to get started, easiest first" diff --git a/data/locales/ways.ru.yml b/data/locales/ways.ru.yml index ee091deb..d3a7a157 100644 --- a/data/locales/ways.ru.yml +++ b/data/locales/ways.ru.yml @@ -19,7 +19,7 @@ ru: buy_text: "Эти производители и интеграторы продают камеры с OpenIPC. Включите камеру и откройте её веб-интерфейс." buy_title: "Купить камеру с уже установленной OpenIPC" buy_when: "Проще всего · без инструментов" - experts_html: "Исследуете новую камеру? Получите на ней консоль и запустите ipctool или поручите ИИ-агенту разобрать прошивку производителя, а затем пришлите нам отчёт. Так новые платы и попадают в этот список." + experts_html: "Исследуете новую камеру? Получите на ней консоль и запустите ipctool или поручите ИИ-агенту разобрать прошивку производителя, а затем пришлите нам отчёт. Так новые платы и попадают в этот список." home_all: "Всё поддерживаемое оборудование" home_lede: "Знать, что такое SoC, не нужно, и паяльник не понадобится ни в одном из них." home_title: "Четыре способа начать, от самого простого" diff --git a/data/locales/ways.zh.yml b/data/locales/ways.zh.yml index f60ad6c8..ac28c441 100644 --- a/data/locales/ways.zh.yml +++ b/data/locales/ways.zh.yml @@ -19,7 +19,7 @@ zh: buy_text: "这些制造商和集成商出售预装 OpenIPC 的摄像机。接上电,打开它的网页界面即可。" buy_title: "购买预装 OpenIPC 的摄像机" buy_when: "最简单 · 无需工具" - experts_html: "在逆向一台新摄像机?拿到它的命令行后运行 ipctool,或者让 AI 编程助手去分析厂商固件,然后把报告发给我们。新的主板就是这样进入这个列表的。" + experts_html: "在逆向一台新摄像机?拿到它的命令行后运行 ipctool,或者让 AI 编程助手去分析厂商固件,然后把报告发给我们。新的主板就是这样进入这个列表的。" home_all: "全部支持的硬件" home_lede: "你不需要知道什么是 SoC,而且都不需要烙铁。" home_title: "四种入门方式,从最简单的开始" diff --git a/frontend/apps/site/public/uget/uget.arm-himix100-linux.sh b/frontend/apps/site/public/uget/uget.arm-himix100-linux.sh new file mode 100644 index 00000000..e9d481e3 --- /dev/null +++ b/frontend/apps/site/public/uget/uget.arm-himix100-linux.sh @@ -0,0 +1,26 @@ +cd /tmp;F=uget;true>$F;chmod +x $F +printf "\x7F\x45\x4C\x46\x1\x1\x1\0\0\0\0\0\0\0\0\0\x2\0\x28\0\x1\0\0\0\xD8\b\x1\0\x34\0\0\0\x4C\x11\0\0\0\x2\0\x5\x34\0\x20\0\a\0\x28\0\x15\0\x14\0\x6\0\0\0\x34\0\0\0\x34\0\x1\0\x34\0\x1\0\xE0\0\0\0\xE0\0\0\0\x5\0\0\0\x4\0\0\0\x3\0\0\0\x14\x1\0\0\x14\x1\x1\0\x14\x1\x1\0\x14\0\0\0\x14\0\0\0\x4\0\0\0\x1\0\0\0\x1\0\0\0\0\0\0\0\0\0\x1\0\0\0\x1\0\x3C\xF\0\0\x3C\xF\0\0\x5\0\0\0\0\0\x1\0\x1\0\0\0\x3C\xF\0\0\x3C\xF\x2\0\x3C\xF\x2\0\x44\x1\0\0\x60\x1\0\0\x6\0\0\0\0\0\x1\0\x2\0\0\0\x48\xF\0\0\x48\xF\x2\0\x48\xF\x2\0\xB8\0\0\0\xB8\0\0\0\x6\0\0\0\x4\0\0\0\x51\xE5\x74\x64\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x6\0\0\0\x10\0\0\0\x52\xE5\x74\x64\x3C\xF\0\0\x3C\xF\x2\0\x3C\xF\x2\0\xC4\0\0\0\xC4\0\0\0\x4\0">$F +printf "\0\0\x1\0\0\0\x2F\x6C\x69\x62\x2F\x6C\x64\x2D\x75\x43\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x30\0\x11\0\0\0\x24\0\0\0\x11\0\0\0\x1E\0\0\0\x15\0\0\0"\0\0\0\x20\0\0\0\xE\0\0\0\x23\0\0\0\x14\0\0\0\x6\0\0\0\x21\0\0\0\f\0\0\0\x1C\0\0\0\x1\0\0\0\x1F\0\0\0\x1A\0\0\0\x17\0\0\0\x1D\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x5\0\0\0\a\0\0\0\0\0\0\0\t\0\0\0\v\0\0\0\b\0\0\0\0\0\0\0\x2\0\0\0\n\0\0\0\0\0\0\0\0\0\0\0\r\0\0\0\0\0\0\0\x13\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xF\0\0\0\x18\0\0\0\0\0\0\0\0\0\0\0\x19\0\0\0\x16\0\0\0\0\0\0\0\x10\0\0\0\x1B\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xA8\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x53\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x65\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\x12\0\0\0\x3E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xE5\0\0\0\x9C\x10\x2\0\0\0\0\0\x10\0\x12\0\x7E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xD6\0\0\0\x80\x10\x2\0\0\0\0\0\x10\0\x12\0\xA1\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x6D\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x58\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xBD\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x38\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xFD\0\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\xE4\0\0\0\x9C\x10\x2\0\0\0\0\0\x10\0\x12\0\x79\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xCF\0\0\0\xD8\b\x1\0\x50\0\0\0\x12\0\b\0\xB6\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x93\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x26\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x72\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x4B\0\0\0\0\0\0\0\0\0\0\0\x12">>$F +printf "\0\0\0\xAF\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x85\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xCA\0\0\0\x80\x10\x2\0\0\0\0\0\x10\0\x12\0\x31\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xF0\0\0\0\x9C\x10\x2\0\0\0\0\0\x10\0\x12\0\x9A\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x1A\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xC3\0\0\0\x80\x10\x2\0\0\0\0\0\x10\0\x11\0\xF8\0\0\0\x9C\x10\x2\0\0\0\0\0\x10\0\x12\0\v\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x15\x1\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\x2B\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x45\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\0\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x30\0\x73\x74\x72\x6C\x65\x6E\0\x6D\x6B\x73\x74\x65\x6D\x70\0\x67\x65\x74\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x66\x6F\x72\x6B">>$F +printf "\0\x63\x6C\x6F\x73\x65\0\x6D\x65\x6D\x73\x65\x74\0\x77\x72\x69\x74\x65\0\x66\x63\x68\x6D\x6F\x64\0\x72\x61\x69\x73\x65\0\x73\x74\x72\x6E\x63\x61\x74\0\x72\x65\x63\x76\0\x66\x72\x65\x65\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x63\x6F\x6E\x6E\x65\x63\x74\0\x73\x65\x6E\x64\0\x73\x73\x63\x61\x6E\x66\0\x77\x61\x69\x74\0\x6D\x65\x6D\x63\x70\x79\0\x5F\x5F\x75\x43\x6C\x69\x62\x63\x5F\x6D\x61\x69\x6E\0\x75\x6E\x6C\x69\x6E\x6B\0\x73\x74\x72\x63\x6D\x70\0\x73\x6F\x63\x6B\x65\x74\0\x73\x74\x72\x63\x70\x79\0\x65\x78\x65\x63\x6C\x70\0\x73\x74\x72\x73\x74\x72\0\x61\x62\x6F\x72\x74\0\x5F\x65\x64\x61\x74\x61\0\x5F\x5F\x62\x73\x73\x5F\x73\x74\x61\x72\x74\0\x5F\x5F\x62\x73\x73\x5F\x73\x74\x61\x72\x74\x5F\x5F\0">>$F +printf "\x5F\x5F\x62\x73\x73\x5F\x65\x6E\x64\x5F\x5F\0\x5F\x5F\x65\x6E\x64\x5F\x5F\0\x5F\x65\x6E\x64\0\x5F\x5F\x64\x65\x72\x65\x67\x69\x73\x74\x65\x72\x5F\x66\x72\x61\x6D\x65\x5F\x69\x6E\x66\x6F\0\x5F\x5F\x72\x65\x67\x69\x73\x74\x65\x72\x5F\x66\x72\x61\x6D\x65\x5F\x69\x6E\x66\x6F\0\0\f\x10\x2\0\x16\x1\0\0\x10\x10\x2\0\x16\x2\0\0\x14\x10\x2\0\x16\x3\0\0\x18\x10\x2\0\x16\x4\0\0\x1C\x10\x2\0\x16\x6\0\0\x20\x10\x2\0\x16\b\0\0\x24\x10\x2\0\x16\t\0\0\x28\x10\x2\0\x16\n\0\0\x2C\x10\x2\0\x16\v\0\0\x30\x10\x2\0\x16\f\0\0\x34\x10\x2\0\x16\r\0\0\x38\x10\x2\0\x16\xF\0\0\x3C\x10\x2\0\x16\x11\0\0\x40\x10\x2\0\x16\x12\0\0\x44\x10\x2\0\x16\x13\0\0\x48\x10\x2\0\x16\x14\0\0\x4C\x10\x2\0\x16\x15\0\0\x50\x10\x2\0">>$F +printf "\x16\x16\0\0\x54\x10\x2\0\x16\x17\0\0\x58\x10\x2\0\x16\x19\0\0\x5C\x10\x2\0\x16\x1B\0\0\x60\x10\x2\0\x16\x1C\0\0\x64\x10\x2\0\x16\x1D\0\0\x68\x10\x2\0\x16\x20\0\0\x6C\x10\x2\0\x16\x21\0\0\x70\x10\x2\0\x16"\0\0\x74\x10\x2\0\x16\x23\0\0\r\xC0\xA0\xE1\xF0\xDF\x2D\xE9\x4\xB0\x4C\xE2\xF0\xAF\x1B\xE9\x4\xE0\x2D\xE5\x4\xE0\x9F\xE5\xE\xE0\x8F\xE0\b\xF0\xBE\xE5\x98\t\x1\0\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x98\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x90\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x88\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x80\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x78\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x70\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x68\xF9\xBC\xE5\0\xC6">>$F +printf "\x8F\xE2\x10\xCA\x8C\xE2\x60\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x58\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x50\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x48\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x40\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x38\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x30\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x28\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x20\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x18\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x10\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\b\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\0\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xF8\xF8\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xF0\xF8\xBC\xE5\0\xC6\x8F\xE2">>$F +printf "\x10\xCA\x8C\xE2\xE8\xF8\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xE0\xF8\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xD8\xF8\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xD0\xF8\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xC8\xF8\xBC\xE5\xF0\x40\x2D\xE9\x1\0\x50\xE3\x1C\xD0\x4D\xE2\v\0\0\xDA\x3\0\x50\xE3\x1\x50\xA0\xE1\0\x40\xA0\x13\x1\x30\xA0\x13\b\0\0\x1A\x4\x10\x91\xE5\xF0\0\x9F\xE5\xDE\xFF\xFF\xEB\0\0\x50\xE3\x1\x40\xA0\x3\x2\x30\xA0\x3\x1\0\0\n\x6\x40\xA0\xE3\x2E\0\0\xEA\x3\x71\x95\xE7\a\x30\xA0\xE1\x3\x10\xA0\xE1\x1\x20\xD3\xE4\0\0\x52\xE3\x2\x50\xA0\x1\x4\0\0\n\x2F\0\x52\xE3\xF8\xFF\xFF\x1A\0\x30\xA0\xE3\x1\x50\x81\xE2\0\x30\xC1\xE5\x10\x20\xA0\xE3\xA0\x10\x9F\xE5\b\0\x8D\xE2\x98\xFF\xFF\xEB\0\0\x54">>$F +printf "\xE3\x1\x60\xA0\x3\x2\0\0\n\b\0\x8D\xE2\xC6\xFF\xFF\xEB\0\x60\xA0\xE1\x5\x20\xA0\xE1\a\x10\xA0\xE1\x6\0\xA0\xE1\x7F\0\0\xEB\0\x50\x50\xE2\x15\0\0\x1A\0\0\x54\xE3\x10\0\0\n\x5\x1D\xA0\xE3\x6\0\xA0\xE1\x84\xFF\xFF\xEB\x6\0\xA0\xE1\xC4\xFF\xFF\xEB\xA2\xFF\xFF\xEB\0\x20\x50\xE2\x5\0\0\n\x4\0\x8D\xE2\x95\xFF\xFF\xEB\x5\x50\xDD\xE5\b\0\x8D\xE2\x98\xFF\xFF\xEB\a\0\0\xEA\b\x10\x8D\xE2\x1\0\xA0\xE1\xA0\xFF\xFF\xEB\x4\0\xA0\xE1\x1C\xD0\x8D\xE2\xF0\x80\xBD\xE8\0\0\x54\xE3\xF4\xFF\xFF\x1A\x5\x40\xA0\xE1\xF8\xFF\xFF\xEA\x21\xF\x1\0\x25\xF\x1\0\0\xB0\xA0\xE3\0\xE0\xA0\xE3\x4\x10\x9D\xE4\r\x20\xA0\xE1\x4\x20\x2D\xE5\x4\0\x2D\xE5\x10\xC0\x9F\xE5\x4\xC0\x2D\xE5\f\0\x9F\xE5\f\x30\x9F\xE5\x8F\xFF\xFF\xEA">>$F +printf "\x70\xFF\xFF\xEB\xE8\xE\x1\0\xB0\a\x1\0\x48\x6\x1\0\x1C\x30\x9F\xE5\x1C\0\x9F\xE5\0\x30\x43\xE0\x6\0\x53\xE3\x1E\xFF\x2F\x91\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x83\x10\x2\0\x80\x10\x2\0\0\0\0\0\x24\x10\x9F\xE5\x24\0\x9F\xE5\0\x10\x41\xE0\x41\x11\xA0\xE1\xA1\x1F\x81\xE0\xC1\x10\xB0\xE1\x1E\xFF\x2F\x1\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x80\x10\x2\0\x80\x10\x2\0\0\0\0\0\x10\x40\x2D\xE9\x2C\x40\x9F\xE5\0\x30\xD4\xE5\0\0\x53\xE3\x10\x80\xBD\x18\xDF\xFF\xFF\xEB\x1C\x30\x9F\xE5\0\0\x53\xE3\x1\0\0\n\x14\0\x9F\xE5\x4E\xFF\xFF\xEB\x1\x30\xA0\xE3\0\x30\xC4\xE5\x10\x80\xBD\xE8\x80\x10\x2\0\0\0\0\0\x38\xF\x1\0\x40\x30\x9F\xE5\x10\x40\x2D\xE9\0\0\x53\xE3">>$F +printf "\x2\0\0\n\x34\x10\x9F\xE5\x34\0\x9F\xE5\x6B\xFF\xFF\xEB\x30\0\x9F\xE5\0\x30\x90\xE5\0\0\x53\xE3\x1\0\0\x1A\x10\x40\xBD\xE8\xD3\xFF\xFF\xEA\x1C\x30\x9F\xE5\0\0\x53\xE3\xFA\xFF\xFF\n\x33\xFF\x2F\xE1\xF8\xFF\xFF\xEA\0\0\0\0\x84\x10\x2\0\x38\xF\x1\0\x44\xF\x2\0\0\0\0\0\x4\xE0\x2D\xE5\x2\xDA\x4D\xE2\x14\xD0\x4D\xE2\x1\x3A\x8D\xE2\x10\x30\x83\xE2\0\x30\x8D\xE5\x10\x20\x8D\xE2\f\x30\x8D\xE2\x18\x10\x9F\xE5\x36\xFF\xFF\xEB\x1\0\x50\xE3\0\0\xE0\xD3\f\0\x9D\xC5\x2\xDA\x8D\xE2\x14\xD0\x8D\xE2\x4\xF0\x9D\xE4\xF8\xE\x1\0\xF0\x4F\x2D\xE9\x1\x60\xA0\xE1\x1\x90\xA0\xE3\x1\xDA\x4D\xE2\x34\xD0\x4D\xE2\x30\x40\x8D\xE2\0\x10\xA0\xE3\0\xA0\xA0\xE1\x2\x70\xA0\xE1\x10\0\x8D\xE2\x20\x20\xA0\xE3\x2F\xFF\xFF\xEB">>$F +printf "\x24\x30\x44\xE2\x10\x20\x8D\xE2\xE4\x11\x9F\xE5\x6\0\xA0\xE1\x18\x90\x4\xE5\x32\xFF\xFF\xEB\0\x30\x50\xE2\0\x50\xE0\x3\0\x30\x8D\xE5\x24\xB0\x14\x5\x5\x80\xA0\x1\xF\0\0\n\x3\x90\xA0\xE3\x5E\0\0\xEA\a\0\x9B\xE9\xF5\xFE\xFF\xEB\0\x50\x50\xE2\x5E\0\0\xBA\x10\x20\x9B\xE5\x14\x10\x9B\xE5\xE7\xFE\xFF\xEB\0\0\x50\xE3\x6\0\0\xAA\x5\0\xA0\xE1\x28\xFF\xFF\xEB\b\x50\xA0\xE1\x4\x90\xA0\xE3\x1C\xB0\x9B\xE5\0\0\x5B\xE3\xEF\xFF\xFF\x1A\x24\0\x14\xE5\xEB\xFE\xFF\xEB\0\0\x55\xE3\x4A\0\0\xBA\x68\x11\x9F\xE5\x4\0\xA0\xE1\xD1\xFE\xFF\xEB\0\0\x57\xE3\x6\0\0\n\x4\0\xA0\xE1\x12\xFF\xFF\xEB\xFF\x2E\x60\xE2\xF\x20\x82\xE2\a\x10\xA0\xE1\x4\0\xA0\xE1\xF8\xFE\xFF\xEB\x4\0\xA0\xE1\v\xFF\xFF\xEB\x34\x71\x9F\xE5\x34">>$F +printf "\x11\x9F\xE5\0\x20\x47\xE0\x4\0\xA0\xE1\xF1\xFE\xFF\xEB\x4\0\xA0\xE1\x4\xFF\xFF\xEB\x6\x10\xA0\xE1\0\x20\x47\xE0\x4\0\xA0\xE1\xEB\xFE\xFF\xEB\x4\0\xA0\xE1\xFE\xFE\xFF\xEB\b\x11\x9F\xE5\0\x20\x47\xE0\x4\0\xA0\xE1\xE5\xFE\xFF\xEB\x4\0\xA0\xE1\xF8\xFE\xFF\xEB\0\x30\xA0\xE3\0\x60\xA0\xE1\0\x20\xA0\xE1\x4\x10\xA0\xE1\x5\0\xA0\xE1\xBF\xFE\xFF\xEB\0\0\x56\xE1\x5\x90\xA0\x13\x20\0\0\x1A\x1\x60\xA0\xE3\0\x80\xA0\xE3\x1\x7A\xA0\xE3\xC0\xB0\x9F\xE5\b\x30\xA0\xE1\a\x20\xA0\xE1\x4\x10\xA0\xE1\x5\0\xA0\xE1\xA4\xFE\xFF\xEB\0\x90\x50\xE2\x15\0\0\n\0\0\x56\xE3\x21\0\0\n\v\x10\xA0\xE1\x4\0\xA0\xE1\xBE\xFE\xFF\xEB\0\x10\x50\xE2\x4\x10\x8D\xE5\xF0\xFF\xFF\n\x4\0\xA0\xE1\x81\xFF\xFF\xEB\xC8\x20\x40\xE2\x63">>$F +printf "\0\x52\xE3\0\x60\xA0\xE1\x4\x10\x9D\xE5\f\0\0\x9A\x64\x10\xA0\xE3\x19\0\0\xEB\n\x10\xA0\xE3\x91\0\t\xE0\x6\0\xA0\xE1\x9D\0\0\xEB\x1\x90\x89\xE0\t\0\xA0\xE1\x1\xDA\x8D\xE2\x34\xD0\x8D\xE2\xF0\x8F\xBD\xE8\x2\x90\xA0\xE3\xA7\xFF\xFF\xEA\x4\x10\x81\xE2\x4\x20\x41\xE0\x2\x90\x49\xE0\t\x20\xA0\xE1\n\0\xA0\xE1\x98\xFE\xFF\xEB\0\x60\x9D\xE5\xD4\xFF\xFF\xEA\x4\x10\xA0\xE1\xF8\xFF\xFF\xEA\x1\xF\x1\0\x4\xF\x1\0\xFF\xF\0\0\n\xF\x1\0\x1C\xF\x1\0\0\0\x51\xE3\x81\0\0\n\x1\xC0\x20\xE0\0\x10\x61\x42\x1\x20\x51\xE2\x70\0\0\n\0\x30\xB0\xE1\0\x30\x60\x42\x1\0\x53\xE1\x6F\0\0\x9A\x2\0\x11\xE1\x71\0\0\n\x13\x2F\x6F\xE1\x11\xF\x6F\xE1\x2\x20\x40\xE0\x1F\x20\x72\xE2\x82\x20\x82\x10\0\0\xA0\xE3\x2\xF1\x8F\x10">>$F +printf "\0\0\xA0\xE1\x81\xF\x53\xE1\0\0\xA0\xE0\x81\x3F\x43\x20\x1\xF\x53\xE1\0\0\xA0\xE0\x1\x3F\x43\x20\x81\xE\x53\xE1\0\0\xA0\xE0\x81\x3E\x43\x20\x1\xE\x53\xE1\0\0\xA0\xE0\x1\x3E\x43\x20\x81\r\x53\xE1\0\0\xA0\xE0\x81\x3D\x43\x20\x1\r\x53\xE1\0\0\xA0\xE0\x1\x3D\x43\x20\x81\f\x53\xE1\0\0\xA0\xE0\x81\x3C\x43\x20\x1\f\x53\xE1\0\0\xA0\xE0\x1\x3C\x43\x20\x81\v\x53\xE1\0\0\xA0\xE0\x81\x3B\x43\x20\x1\v\x53\xE1\0\0\xA0\xE0\x1\x3B\x43\x20\x81\n\x53\xE1\0\0\xA0\xE0\x81\x3A\x43\x20\x1\n\x53\xE1\0\0\xA0\xE0\x1\x3A\x43\x20\x81\t\x53\xE1\0\0\xA0\xE0\x81\x39\x43\x20\x1\t\x53\xE1\0\0\xA0\xE0\x1\x39\x43\x20\x81\b\x53\xE1\0\0\xA0\xE0\x81\x38\x43\x20\x1\b\x53\xE1\0\0\xA0\xE0\x1\x38\x43\x20\x81\a\x53\xE1\0\0\xA0">>$F +printf "\xE0\x81\x37\x43\x20\x1\a\x53\xE1\0\0\xA0\xE0\x1\x37\x43\x20\x81\x6\x53\xE1\0\0\xA0\xE0\x81\x36\x43\x20\x1\x6\x53\xE1\0\0\xA0\xE0\x1\x36\x43\x20\x81\x5\x53\xE1\0\0\xA0\xE0\x81\x35\x43\x20\x1\x5\x53\xE1\0\0\xA0\xE0\x1\x35\x43\x20\x81\x4\x53\xE1\0\0\xA0\xE0\x81\x34\x43\x20\x1\x4\x53\xE1\0\0\xA0\xE0\x1\x34\x43\x20\x81\x3\x53\xE1\0\0\xA0\xE0\x81\x33\x43\x20\x1\x3\x53\xE1\0\0\xA0\xE0\x1\x33\x43\x20\x81\x2\x53\xE1\0\0\xA0\xE0\x81\x32\x43\x20\x1\x2\x53\xE1\0\0\xA0\xE0\x1\x32\x43\x20\x81\x1\x53\xE1\0\0\xA0\xE0\x81\x31\x43\x20\x1\x1\x53\xE1\0\0\xA0\xE0\x1\x31\x43\x20\x81\0\x53\xE1\0\0\xA0\xE0\x81\x30\x43\x20\x1\0\x53\xE1\0\0\xA0\xE0\x1\x30\x43\x20\0\0\x5C\xE3\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x3C">>$F +printf "\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\xA0\x33\xCC\xF\xA0\x1\x1\0\x80\x3\x1E\xFF\x2F\xE1\x11\x2F\x6F\xE1\x1F\x20\x62\xE2\0\0\x5C\xE3\x33\x2\xA0\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x50\xE3\x2\x1\xE0\xC3\x2\x1\xA0\xB3\a\0\0\xEA\0\0\x51\xE3\xF9\xFF\xFF\n\x3\x40\x2D\xE9\x75\xFF\xFF\xEB\x6\x40\xBD\xE8\x92\0\x3\xE0\x3\x10\x41\xE0\x1E\xFF\x2F\xE1\x2\x40\x2D\xE9\b\0\xA0\xE3\x2F\xFE\xFF\xEB\x2\x80\xBD\xE8\r\xC0\xA0\xE1\xF0\xDF\x2D\xE9\x4\xB0\x4C\xE2\xF0\xAF\x1B\xE9\x25\x73\x20\x25\x64\x20\x25\x73\0\x38\x30\0\x47\x45\x54\x20\x2F\0\x20\x48\x54\x54\x50\x2F\x31\x2E\x30\r\n\x48\x6F\x73\x74\x3A\x20\0\r\n\r\n\0\x72\x75\x6E\0\x2F\x74\x6D\x70\x2F\x75\x67\x65\x74\x58\x58\x58\x58\x58\x58\0\0\0\0\0\0\0\0\xC0">>$F +printf "\t\x1\0\x7C\t\x1\0\0\0\0\0\x1\0\0\0\x1\0\0\0\f\0\0\0\x48\x6\x1\0\r\0\0\0\xE8\xE\x1\0\x19\0\0\0\x3C\xF\x2\0\x1B\0\0\0\x4\0\0\0\x1A\0\0\0\x40\xF\x2\0\x1C\0\0\0\x4\0\0\0\x4\0\0\0\x28\x1\x1\0\x5\0\0\0\x44\x4\x1\0\x6\0\0\0\x4\x2\x1\0\n\0\0\0\x2B\x1\0\0\v\0\0\0\x10\0\0\0\x15\0\0\0\0\0\0\0\x3\0\0\0\0\x10\x2\0\x2\0\0\0\xD8\0\0\0\x14\0\0\0\x11\0\0\0\x17\0\0\0\x70\x5\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x48\xF\x2\0\0\0\0\0\0\0\0\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58">>$F +printf "\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\x58\x6\x1\0\0\0\0\0\0\0\0\0\x41\x2D\0\0\0\x61\x65\x61\x62\x69\0\x1\x23\0\0\0\x5\x41\x52\x4D\x39\x32\x36\x45\x4A\x2D\x53\0\x6\x5\b\x1\t\x1\x12\x4\x14\x1\x15\x1\x17\x3\x18\x1\x1A\x2\0\x2E\x73\x68\x73\x74\x72\x74\x61\x62\0\x2E\x69\x6E\x74\x65\x72\x70\0\x2E\x68\x61\x73\x68\0\x2E\x64\x79\x6E\x73\x79\x6D\0\x2E\x64\x79\x6E\x73\x74\x72\0\x2E\x72\x65\x6C\x2E\x70\x6C\x74\0\x2E\x69\x6E\x69\x74\0\x2E\x74\x65\x78\x74\0\x2E\x66\x69\x6E\x69\0\x2E\x72\x6F\x64\x61\x74\x61\0\x2E\x65\x68\x5F\x66\x72\x61\x6D\x65\0\x2E\x69\x6E\x69\x74\x5F\x61\x72\x72\x61\x79\0\x2E\x66\x69\x6E\x69\x5F\x61\x72\x72\x61\x79">>$F +printf "\0\x2E\x6A\x63\x72\0\x2E\x64\x79\x6E\x61\x6D\x69\x63\0\x2E\x67\x6F\x74\0\x2E\x64\x61\x74\x61\0\x2E\x62\x73\x73\0\x2E\x41\x52\x4D\x2E\x61\x74\x74\x72\x69\x62\x75\x74\x65\x73\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\v\0\0\0\x1\0\0\0\x2\0\0\0\x14\x1\x1\0\x14\x1\0\0\x14\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x13\0\0\0\x5\0\0\0\x2\0\0\0\x28\x1\x1\0\x28\x1\0\0\xDC\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x19\0\0\0\v\0\0\0\x2\0\0\0\x4\x2\x1\0\x4\x2\0\0\x40\x2\0\0\x4\0\0\0\x1\0\0\0\x4\0\0\0\x10\0\0\0\x21\0\0\0\x3\0\0\0\x2\0\0\0\x44\x4\x1\0\x44\x4\0\0\x2B\x1\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x29\0\0\0\t\0\0\0\x42\0\0\0\x70\x5\x1\0\x70\x5\0\0\xD8\0">>$F +printf "\0\0\x3\0\0\0\x10\0\0\0\x4\0\0\0\b\0\0\0\x32\0\0\0\x1\0\0\0\x6\0\0\0\x48\x6\x1\0\x48\x6\0\0\x10\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x2D\0\0\0\x1\0\0\0\x6\0\0\0\x58\x6\x1\0\x58\x6\0\0\x58\x1\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x38\0\0\0\x1\0\0\0\x6\0\0\0\xB0\a\x1\0\xB0\a\0\0\x38\a\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x3E\0\0\0\x1\0\0\0\x6\0\0\0\xE8\xE\x1\0\xE8\xE\0\0\x10\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x44\0\0\0\x1\0\0\0\x32\0\0\0\xF8\xE\x1\0\xF8\xE\0\0\x3D\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\x1\0\0\0\x4C\0\0\0\x1\0\0\0\x2\0\0\0\x38\xF\x1\0\x38\xF\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x56\0\0\0\xE\0\0\0\x3\0\0\0\x3C\xF\x2\0\x3C\xF\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4">>$F +printf "\0\0\0\x62\0\0\0\xF\0\0\0\x3\0\0\0\x40\xF\x2\0\x40\xF\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x6E\0\0\0\x1\0\0\0\x3\0\0\0\x44\xF\x2\0\x44\xF\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x73\0\0\0\x6\0\0\0\x3\0\0\0\x48\xF\x2\0\x48\xF\0\0\xB8\0\0\0\x4\0\0\0\0\0\0\0\x4\0\0\0\b\0\0\0\x7C\0\0\0\x1\0\0\0\x3\0\0\0\0\x10\x2\0\0\x10\0\0\x78\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x81\0\0\0\x1\0\0\0\x3\0\0\0\x78\x10\x2\0\x78\x10\0\0\b\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x87\0\0\0\b\0\0\0\x3\0\0\0\x80\x10\x2\0\x80\x10\0\0\x1C\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x8C\0\0\0\x3\0\0\x70\0\0\0\0\0\0\0\0\x80\x10\0\0\x2E\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x1\0\0\0\x3\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\xAE\x10\0\0\x9C\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0">>$F + diff --git a/frontend/apps/site/public/uget/uget.arm-himix200-linux.sh b/frontend/apps/site/public/uget/uget.arm-himix200-linux.sh new file mode 100644 index 00000000..662e3205 --- /dev/null +++ b/frontend/apps/site/public/uget/uget.arm-himix200-linux.sh @@ -0,0 +1,38 @@ +cd /tmp;F=uget;true>$F;chmod +x $F +printf "\x7F\x45\x4C\x46\x1\x1\x1\0\0\0\0\0\0\0\0\0\x2\0\x28\0\x1\0\0\0\xA4\b\x1\0\x34\0\0\0\x80\x21\0\0\0\x2\0\x5\x34\0\x20\0\b\0\x28\0\x19\0\x18\0\x1\0\0\x70\x48\xF\0\0\x48\xF\x1\0\x48\xF\x1\0\b\0\0\0\b\0\0\0\x4\0\0\0\x4\0\0\0\x6\0\0\0\x34\0\0\0\x34\0\x1\0\x34\0\x1\0\0\x1\0\0\0\x1\0\0\x5\0\0\0\x4\0\0\0\x3\0\0\0\x54\x1\0\0\x54\x1\x1\0\x54\x1\x1\0\x13\0\0\0\x13\0\0\0\x4\0\0\0\x1\0\0\0\x1\0\0\0\0\0\0\0\0\0\x1\0\0\0\x1\0\x54\xF\0\0\x54\xF\0\0\x5\0\0\0\0\0\x1\0\x1\0\0\0\f\x1F\0\0\f\x1F\x2\0\f\x1F\x2\0\x74\x1\0\0\x78\x1\0\0\x6\0\0\0\0\0\x1\0\x2\0\0\0\x18\x1F\0\0\x18\x1F\x2\0\x18\x1F\x2\0\xE8\0\0\0\xE8\0\0\0\x6\0\0\0\x4\0\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x51\xE5">$F +printf "\x74\x64\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x6\0\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x2F\x6C\x69\x62\x2F\x6C\x64\x2D\x6C\x69\x6E\x75\x78\x2E\x73\x6F\x2E\x33\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x11\0\0\0\x1B\0\0\0\r\0\0\0\0\0\0\0\x14\0\0\0\x17\0\0\0\v\0\0\0\0\0\0\0\x16\0\0\0\t\0\0\0\x1A\0\0\0\0\0\0\0\x18\0\0\0\f\0\0\0\b\0\0\0\xE\0\0\0\0\0\0\0\n\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x2\0\0\0\0\0\0\0\0\0\0\0\x5\0\0\0\x3\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x6\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x10\0\0\0\x13\0\0\0\xF\0\0\0\a\0\0\0\x11\0\0\0\x15\0\0\0\x4\0\0\0\x19\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\x7D\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x90\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xB1\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x89\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x57\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x42\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x20\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xB8\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xE4\0\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\x5E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x4F\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x25\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x76\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xCA\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x3D\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xA4\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x6A\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\v\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x96">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x49\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x84\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x9E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x65\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x35\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x19\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\0\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x36\0\x73\x6F\x63\x6B\x65\x74\0\x73\x74\x72\x63\x70\x79\0\x66\x63\x68\x6D\x6F\x64\0\x77\x61\x69\x74\0\x5F\x5F\x69\x73\x6F\x63\x39\x39\x5F\x73\x73\x63\x61\x6E\x66\0\x63\x6F\x6E\x6E\x65\x63\x74\0\x66\x6F\x72\x6B\0\x75\x6E\x6C\x69\x6E\x6B\0\x61\x62\x6F\x72\x74\0\x6D\x6B\x73\x74\x65\x6D\x70\0\x65\x78\x65\x63\x6C\x70\0\x73\x74\x72\x6C\x65\x6E\0\x73\x65\x6E\x64\0\x67\x65\x74\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x6D\x65">>$F +printf "\x6D\x73\x65\x74\0\x73\x74\x72\x73\x74\x72\0\x72\x65\x63\x76\0\x6D\x65\x6D\x63\x70\x79\0\x72\x61\x69\x73\x65\0\x73\x74\x72\x6E\x63\x61\x74\0\x63\x6C\x6F\x73\x65\0\x66\x72\x65\x65\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x73\x74\x72\x63\x6D\x70\0\x5F\x5F\x6C\x69\x62\x63\x5F\x73\x74\x61\x72\x74\x5F\x6D\x61\x69\x6E\0\x77\x72\x69\x74\x65\0\x47\x4C\x49\x42\x43\x5F\x32\x2E\x37\0\x47\x4C\x49\x42\x43\x5F\x32\x2E\x34\0\x5F\x5F\x67\x6D\x6F\x6E\x5F\x73\x74\x61\x72\x74\x5F\x5F\0\0\0\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\0\0\x2\0\x2\0\x3\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\0\0\x1\0\x2\0\x1\0\0\0\x10\0\0\0\0\0\0\0\x17\x69\x69\r\0\0\x3\0\xD0\0\0\0\x10\0\0\0\x14\x69\x69">>$F +printf "\r\0\0\x2\0\xDA\0\0\0\0\0\0\0\x74\x20\x2\0\x15\n\0\0\f\x20\x2\0\x16\x1\0\0\x10\x20\x2\0\x16\x2\0\0\x14\x20\x2\0\x16\x3\0\0\x18\x20\x2\0\x16\x4\0\0\x1C\x20\x2\0\x16\x5\0\0\x20\x20\x2\0\x16\x6\0\0\x24\x20\x2\0\x16\a\0\0\x28\x20\x2\0\x16\b\0\0\x2C\x20\x2\0\x16\t\0\0\x30\x20\x2\0\x16\n\0\0\x34\x20\x2\0\x16\v\0\0\x38\x20\x2\0\x16\f\0\0\x3C\x20\x2\0\x16\r\0\0\x40\x20\x2\0\x16\xE\0\0\x44\x20\x2\0\x16\xF\0\0\x48\x20\x2\0\x16\x10\0\0\x4C\x20\x2\0\x16\x11\0\0\x50\x20\x2\0\x16\x12\0\0\x54\x20\x2\0\x16\x13\0\0\x58\x20\x2\0\x16\x14\0\0\x5C\x20\x2\0\x16\x15\0\0\x60\x20\x2\0\x16\x16\0\0\x64\x20\x2\0\x16\x17\0\0\x68\x20\x2\0\x16\x18\0\0\x6C\x20\x2\0\x16\x19\0\0\x70\x20\x2\0\x16\x1A\0\0\b\x40\x2D\xE9\xAC">>$F +printf "\0\0\xEB\b\x80\xBD\xE8\x4\xE0\x2D\xE5\x4\xE0\x9F\xE5\xE\xE0\x8F\xE0\b\xF0\xBE\xE5\xC0\x19\x1\0\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\xC0\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\xB8\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\xB0\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\xA8\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\xA0\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x98\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x90\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x88\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x80\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x78\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x70\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x68\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x60\xF9\xBC\xE5\0">>$F +printf "\xC6\x8F\xE2\x11\xCA\x8C\xE2\x58\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x50\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x48\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x40\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x38\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x30\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x28\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x20\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x18\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\x10\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\b\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\0\xF9\xBC\xE5\0\xC6\x8F\xE2\x11\xCA\x8C\xE2\xF8\xF8\xBC\xE5\xF0\x40\x2D\xE9\x1\0\x50\xE3\x1C\xD0\x4D\xE2\v\0\0\xDA\x3\0\x50\xE3\x1\x50\xA0\xE1\0\x40\xA0\x13\x1\x30">>$F +printf "\xA0\x13\b\0\0\x1A\x4\x10\x91\xE5\xF0\0\x9F\xE5\xAB\xFF\xFF\xEB\0\0\x50\xE3\x1\x40\xA0\x3\x2\x30\xA0\x3\x1\0\0\n\x6\x40\xA0\xE3\x2E\0\0\xEA\x3\x71\x95\xE7\a\x30\xA0\xE1\x3\x10\xA0\xE1\x1\x20\xD3\xE4\0\0\x52\xE3\x2\x50\xA0\x1\x4\0\0\n\x2F\0\x52\xE3\xF8\xFF\xFF\x1A\0\x30\xA0\xE3\x1\x50\x81\xE2\0\x30\xC1\xE5\x10\x20\xA0\xE3\xA0\x10\x9F\xE5\b\0\x8D\xE2\x98\xFF\xFF\xEB\0\0\x54\xE3\x1\x60\xA0\x3\x2\0\0\n\b\0\x8D\xE2\xAB\xFF\xFF\xEB\0\x60\xA0\xE1\x5\x20\xA0\xE1\a\x10\xA0\xE1\x6\0\xA0\xE1\x78\0\0\xEB\0\x50\x50\xE2\x15\0\0\x1A\0\0\x54\xE3\x10\0\0\n\x5\x1D\xA0\xE3\x6\0\xA0\xE1\xC9\xFF\xFF\xEB\x6\0\xA0\xE1\xBE\xFF\xFF\xEB\xA8\xFF\xFF\xEB\0\x20\x50\xE2\x5\0\0\n\x4\0\x8D\xE2\x89\xFF\xFF\xEB\x5\x50">>$F +printf "\xDD\xE5\b\0\x8D\xE2\x83\xFF\xFF\xEB\a\0\0\xEA\b\x10\x8D\xE2\x1\0\xA0\xE1\x7C\xFF\xFF\xEB\x4\0\xA0\xE1\x1C\xD0\x8D\xE2\xF0\x80\xBD\xE8\0\0\x54\xE3\xF4\xFF\xFF\x1A\x5\x40\xA0\xE1\xF8\xFF\xFF\xEA\x31\xF\x1\0\x35\xF\x1\0\0\xB0\xA0\xE3\0\xE0\xA0\xE3\x4\x10\x9D\xE4\r\x20\xA0\xE1\x4\x20\x2D\xE5\x4\0\x2D\xE5\x10\xC0\x9F\xE5\x4\xC0\x2D\xE5\f\0\x9F\xE5\f\x30\x9F\xE5\x74\xFF\xFF\xEB\x97\xFF\xFF\xEB\xF8\xE\x1\0\x7C\a\x1\0\x98\xE\x1\0\x14\x30\x9F\xE5\x14\x20\x9F\xE5\x3\x30\x8F\xE0\x2\x20\x93\xE7\0\0\x52\xE3\x1E\xFF\x2F\x1\x6C\xFF\xFF\xEA\x10\x17\x1\0\x74\0\0\0\x1C\x30\x9F\xE5\x1C\0\x9F\xE5\0\x30\x43\xE0\x6\0\x53\xE3\x1E\xFF\x2F\x91\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x83\x20">>$F +printf "\x2\0\x80\x20\x2\0\0\0\0\0\x24\x10\x9F\xE5\x24\0\x9F\xE5\0\x10\x41\xE0\x41\x11\xA0\xE1\xA1\x1F\x81\xE0\xC1\x10\xB0\xE1\x1E\xFF\x2F\x1\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x80\x20\x2\0\x80\x20\x2\0\0\0\0\0\x10\x40\x2D\xE9\x18\x40\x9F\xE5\0\x30\xD4\xE5\0\0\x53\xE3\x10\x80\xBD\x18\xDF\xFF\xFF\xEB\x1\x30\xA0\xE3\0\x30\xC4\xE5\x10\x80\xBD\xE8\x80\x20\x2\0\x28\0\x9F\xE5\0\x30\x90\xE5\0\0\x53\xE3\0\0\0\x1A\xE2\xFF\xFF\xEA\x18\x30\x9F\xE5\0\0\x53\xE3\xFB\xFF\xFF\n\x10\x40\x2D\xE9\x33\xFF\x2F\xE1\x10\x40\xBD\xE8\xDB\xFF\xFF\xEA\x14\x1F\x2\0\0\0\0\0\x4\xE0\x2D\xE5\x2\xDA\x4D\xE2\x14\xD0\x4D\xE2\x1\x3A\x8D\xE2\x10\x30\x83\xE2\0\x30\x8D\xE5\x10\x20\x8D\xE2\f\x30\x8D\xE2\x18\x10">>$F +printf "\x9F\xE5\x37\xFF\xFF\xEB\x1\0\x50\xE3\0\0\xE0\xD3\f\0\x9D\xC5\x2\xDA\x8D\xE2\x14\xD0\x8D\xE2\x4\xF0\x9D\xE4\b\xF\x1\0\xF0\x4F\x2D\xE9\x1\x60\xA0\xE1\x1\x90\xA0\xE3\x1\xDA\x4D\xE2\x34\xD0\x4D\xE2\x30\x40\x8D\xE2\0\x10\xA0\xE3\0\xA0\xA0\xE1\x2\x70\xA0\xE1\x10\0\x8D\xE2\x20\x20\xA0\xE3\x27\xFF\xFF\xEB\x24\x30\x44\xE2\x10\x20\x8D\xE2\xE4\x11\x9F\xE5\x6\0\xA0\xE1\x18\x90\x4\xE5\x2D\xFF\xFF\xEB\0\x30\x50\xE2\0\x50\xE0\x3\0\x30\x8D\xE5\x24\xB0\x14\x5\x5\x80\xA0\x1\xF\0\0\n\x3\x90\xA0\xE3\x5E\0\0\xEA\a\0\x9B\xE9\x26\xFF\xFF\xEB\0\x50\x50\xE2\x5E\0\0\xBA\x10\x20\x9B\xE5\x14\x10\x9B\xE5\x33\xFF\xFF\xEB\0\0\x50\xE3\x6\0\0\xAA\x5\0\xA0\xE1\x29\xFF\xFF\xEB\b\x50\xA0\xE1\x4\x90\xA0\xE3\x1C\xB0\x9B\xE5">>$F +printf "\0\0\x5B\xE3\xEF\xFF\xFF\x1A\x24\0\x14\xE5\x10\xFF\xFF\xEB\0\0\x55\xE3\x4A\0\0\xBA\x68\x11\x9F\xE5\x4\0\xA0\xE1\xF0\xFE\xFF\xEB\0\0\x57\xE3\x6\0\0\n\x4\0\xA0\xE1\xF5\xFE\xFF\xEB\xFF\x2E\x60\xE2\xF\x20\x82\xE2\a\x10\xA0\xE1\x4\0\xA0\xE1\v\xFF\xFF\xEB\x4\0\xA0\xE1\xEE\xFE\xFF\xEB\x34\x71\x9F\xE5\x34\x11\x9F\xE5\0\x20\x47\xE0\x4\0\xA0\xE1\x4\xFF\xFF\xEB\x4\0\xA0\xE1\xE7\xFE\xFF\xEB\x6\x10\xA0\xE1\0\x20\x47\xE0\x4\0\xA0\xE1\xFE\xFE\xFF\xEB\x4\0\xA0\xE1\xE1\xFE\xFF\xEB\b\x11\x9F\xE5\0\x20\x47\xE0\x4\0\xA0\xE1\xF8\xFE\xFF\xEB\x4\0\xA0\xE1\xDB\xFE\xFF\xEB\0\x30\xA0\xE3\0\x60\xA0\xE1\0\x20\xA0\xE1\x4\x10\xA0\xE1\x5\0\xA0\xE1\xFC\xFE\xFF\xEB\0\0\x56\xE1\x5\x90\xA0\x13\x20\0\0\x1A\x1\x60\xA0\xE3">>$F +printf "\0\x80\xA0\xE3\x1\x7A\xA0\xE3\xC0\xB0\x9F\xE5\b\x30\xA0\xE1\a\x20\xA0\xE1\x4\x10\xA0\xE1\x5\0\xA0\xE1\xEA\xFE\xFF\xEB\0\x90\x50\xE2\x15\0\0\n\0\0\x56\xE3\x21\0\0\n\v\x10\xA0\xE1\x4\0\xA0\xE1\xA4\xFE\xFF\xEB\0\x10\x50\xE2\x4\x10\x8D\xE5\xF0\xFF\xFF\n\x4\0\xA0\xE1\x81\xFF\xFF\xEB\xC8\x20\x40\xE2\x63\0\x52\xE3\0\x60\xA0\xE1\x4\x10\x9D\xE5\f\0\0\x9A\x64\x10\xA0\xE3\x19\0\0\xEB\n\x10\xA0\xE3\x91\0\t\xE0\x6\0\xA0\xE1\x9D\0\0\xEB\x1\x90\x89\xE0\t\0\xA0\xE1\x1\xDA\x8D\xE2\x34\xD0\x8D\xE2\xF0\x8F\xBD\xE8\x2\x90\xA0\xE3\xA7\xFF\xFF\xEA\x4\x10\x81\xE2\x4\x20\x41\xE0\x2\x90\x49\xE0\t\x20\xA0\xE1\n\0\xA0\xE1\xB1\xFE\xFF\xEB\0\x60\x9D\xE5\xD4\xFF\xFF\xEA\x4\x10\xA0\xE1\xF8\xFF\xFF\xEA\x11\xF\x1\0\x14">>$F +printf "\xF\x1\0\xFF\xF\0\0\x1A\xF\x1\0\x2C\xF\x1\0\0\0\x51\xE3\x81\0\0\n\x1\xC0\x20\xE0\0\x10\x61\x42\x1\x20\x51\xE2\x70\0\0\n\0\x30\xB0\xE1\0\x30\x60\x42\x1\0\x53\xE1\x6F\0\0\x9A\x2\0\x11\xE1\x71\0\0\n\x13\x2F\x6F\xE1\x11\xF\x6F\xE1\x2\x20\x40\xE0\x1F\x20\x72\xE2\x82\x20\x82\x10\0\0\xA0\xE3\x2\xF1\x8F\x10\0\0\xA0\xE1\x81\xF\x53\xE1\0\0\xA0\xE0\x81\x3F\x43\x20\x1\xF\x53\xE1\0\0\xA0\xE0\x1\x3F\x43\x20\x81\xE\x53\xE1\0\0\xA0\xE0\x81\x3E\x43\x20\x1\xE\x53\xE1\0\0\xA0\xE0\x1\x3E\x43\x20\x81\r\x53\xE1\0\0\xA0\xE0\x81\x3D\x43\x20\x1\r\x53\xE1\0\0\xA0\xE0\x1\x3D\x43\x20\x81\f\x53\xE1\0\0\xA0\xE0\x81\x3C\x43\x20\x1\f\x53\xE1\0\0\xA0\xE0\x1\x3C\x43\x20\x81\v\x53\xE1\0\0\xA0\xE0\x81\x3B\x43\x20\x1\v\x53">>$F +printf "\xE1\0\0\xA0\xE0\x1\x3B\x43\x20\x81\n\x53\xE1\0\0\xA0\xE0\x81\x3A\x43\x20\x1\n\x53\xE1\0\0\xA0\xE0\x1\x3A\x43\x20\x81\t\x53\xE1\0\0\xA0\xE0\x81\x39\x43\x20\x1\t\x53\xE1\0\0\xA0\xE0\x1\x39\x43\x20\x81\b\x53\xE1\0\0\xA0\xE0\x81\x38\x43\x20\x1\b\x53\xE1\0\0\xA0\xE0\x1\x38\x43\x20\x81\a\x53\xE1\0\0\xA0\xE0\x81\x37\x43\x20\x1\a\x53\xE1\0\0\xA0\xE0\x1\x37\x43\x20\x81\x6\x53\xE1\0\0\xA0\xE0\x81\x36\x43\x20\x1\x6\x53\xE1\0\0\xA0\xE0\x1\x36\x43\x20\x81\x5\x53\xE1\0\0\xA0\xE0\x81\x35\x43\x20\x1\x5\x53\xE1\0\0\xA0\xE0\x1\x35\x43\x20\x81\x4\x53\xE1\0\0\xA0\xE0\x81\x34\x43\x20\x1\x4\x53\xE1\0\0\xA0\xE0\x1\x34\x43\x20\x81\x3\x53\xE1\0\0\xA0\xE0\x81\x33\x43\x20\x1\x3\x53\xE1\0\0\xA0\xE0\x1\x33\x43\x20">>$F +printf "\x81\x2\x53\xE1\0\0\xA0\xE0\x81\x32\x43\x20\x1\x2\x53\xE1\0\0\xA0\xE0\x1\x32\x43\x20\x81\x1\x53\xE1\0\0\xA0\xE0\x81\x31\x43\x20\x1\x1\x53\xE1\0\0\xA0\xE0\x1\x31\x43\x20\x81\0\x53\xE1\0\0\xA0\xE0\x81\x30\x43\x20\x1\0\x53\xE1\0\0\xA0\xE0\x1\x30\x43\x20\0\0\x5C\xE3\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x3C\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\xA0\x33\xCC\xF\xA0\x1\x1\0\x80\x3\x1E\xFF\x2F\xE1\x11\x2F\x6F\xE1\x1F\x20\x62\xE2\0\0\x5C\xE3\x33\x2\xA0\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x50\xE3\x2\x1\xE0\xC3\x2\x1\xA0\xB3\a\0\0\xEA\0\0\x51\xE3\xF9\xFF\xFF\n\x3\x40\x2D\xE9\x75\xFF\xFF\xEB\x6\x40\xBD\xE8\x92\0\x3\xE0\x3\x10\x41\xE0\x1E\xFF\x2F\xE1\x2\x40\x2D\xE9\b\0\xA0\xE3\xEE\xFD\xFF\xEB\x2\x80\xBD\xE8">>$F +printf "\xF0\x47\x2D\xE9\x4C\x60\x9F\xE5\x4C\x50\x9F\xE5\x6\x60\x8F\xE0\x5\x50\x8F\xE0\x5\x60\x46\xE0\0\x70\xA0\xE1\x1\x80\xA0\xE1\x2\x90\xA0\xE1\xD8\xFD\xFF\xEB\x46\x61\xB0\xE1\xF0\x87\xBD\b\0\x40\xA0\xE3\x1\x40\x84\xE2\x4\x30\x95\xE4\t\x20\xA0\xE1\b\x10\xA0\xE1\a\0\xA0\xE1\x33\xFF\x2F\xE1\x4\0\x56\xE1\xF7\xFF\xFF\x1A\xF0\x87\xBD\xE8\x64\x10\x1\0\x5C\x10\x1\0\x1E\xFF\x2F\xE1\b\x40\x2D\xE9\b\x80\xBD\xE8\x1\0\x2\0\x25\x73\x20\x25\x64\x20\x25\x73\0\x38\x30\0\x47\x45\x54\x20\x2F\0\x20\x48\x54\x54\x50\x2F\x31\x2E\x30\r\n\x48\x6F\x73\x74\x3A\x20\0\r\n\r\n\0\x72\x75\x6E\0\x2F\x74\x6D\x70\x2F\x75\x67\x65\x74\x58\x58\x58\x58\x58\x58\0\0\0\0\x5C\xF9\xFF\x7F\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x94\t\x1\0\x6C\t\x1\0\0\0\0\0\x1\0\0\0\x1\0\0\0\f\0\0\0\x24\x6\x1\0\r\0\0\0\xFC\xE\x1\0\x19\0\0\0\f\x1F\x2\0\x1B\0\0\0\x4\0\0\0\x1A\0\0\0\x10\x1F\x2\0\x1C\0\0\0\x4\0\0\0\x4\0\0\0\x88\x1\x1\0\x5\0\0\0\xF0\x3\x1\0\x6\0\0\0\x40\x2\x1\0\n\0\0\0\xF3\0\0\0\v\0\0\0\x10\0\0\0\x15\0\0\0\0\0\0\0\x3\0\0\0\0\x20\x2\0">>$F +printf "\x2\0\0\0\xD0\0\0\0\x14\0\0\0\x11\0\0\0\x17\0\0\0\x54\x5\x1\0\x11\0\0\0\x4C\x5\x1\0\x12\0\0\0\b\0\0\0\x13\0\0\0\b\0\0\0\xFE\xFF\xFF\x6F\x1C\x5\x1\0\xFF\xFF\xFF\x6F\x1\0\0\0\xF0\xFF\xFF\x6F\xE4\x4\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x18\x1F\x2\0\0\0\0\0\0\0\0\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\x30\x6\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\x41\x31\0\0\0\x61\x65\x61\x62\x69">>$F +printf "\0\x1\x27\0\0\0\x5\x41\x52\x4D\x39\x32\x36\x45\x4A\x2D\x53\0\x6\x5\b\x1\t\x1\x12\x4\x13\x1\x14\x1\x15\x1\x17\x3\x18\x1\x19\x1\x1A\x2\0\x2E\x73\x68\x73\x74\x72\x74\x61\x62\0\x2E\x69\x6E\x74\x65\x72\x70\0\x2E\x68\x61\x73\x68\0\x2E\x64\x79\x6E\x73\x79\x6D\0\x2E\x64\x79\x6E\x73\x74\x72\0\x2E\x67\x6E\x75\x2E\x76\x65\x72\x73\x69\x6F\x6E\0\x2E\x67\x6E\x75\x2E\x76\x65\x72\x73\x69\x6F\x6E\x5F\x72\0\x2E\x72\x65\x6C\x2E\x64\x79\x6E\0\x2E\x72\x65\x6C\x2E\x70\x6C\x74\0\x2E\x69\x6E\x69\x74\0\x2E\x74\x65\x78\x74\0\x2E\x66\x69\x6E\x69\0\x2E\x72\x6F\x64\x61\x74\x61\0\x2E\x41\x52\x4D\x2E\x65\x78\x69\x64\x78\0\x2E\x65\x68\x5F\x66\x72\x61\x6D\x65\0\x2E\x69\x6E\x69\x74\x5F\x61\x72\x72\x61\x79\0\x2E\x66\x69">>$F +printf "\x6E\x69\x5F\x61\x72\x72\x61\x79\0\x2E\x6A\x63\x72\0\x2E\x64\x79\x6E\x61\x6D\x69\x63\0\x2E\x67\x6F\x74\0\x2E\x64\x61\x74\x61\0\x2E\x62\x73\x73\0\x2E\x41\x52\x4D\x2E\x61\x74\x74\x72\x69\x62\x75\x74\x65\x73\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\v\0\0\0\x1\0\0\0\x2\0\0\0\x54\x1\x1\0\x54\x1\0\0\x13\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x13\0\0\0\x5\0\0\0\x2\0\0\0\x88\x1\x1\0\x88\x1\0\0\xB8\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x19\0\0\0\v\0\0\0\x2\0\0\0\x40\x2\x1\0\x40\x2\0\0\xB0\x1\0\0\x4\0\0\0\x1\0\0\0\x4\0\0\0\x10\0\0\0\x21\0\0\0\x3\0\0\0\x2\0\0\0\xF0\x3\x1\0\xF0\x3\0\0\xF3\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x29\0\0\0\xFF\xFF\xFF\x6F">>$F +printf "\x2\0\0\0\xE4\x4\x1\0\xE4\x4\0\0\x36\0\0\0\x3\0\0\0\0\0\0\0\x2\0\0\0\x2\0\0\0\x36\0\0\0\xFE\xFF\xFF\x6F\x2\0\0\0\x1C\x5\x1\0\x1C\x5\0\0\x30\0\0\0\x4\0\0\0\x1\0\0\0\x4\0\0\0\0\0\0\0\x45\0\0\0\t\0\0\0\x2\0\0\0\x4C\x5\x1\0\x4C\x5\0\0\b\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\b\0\0\0\x4E\0\0\0\t\0\0\0\x42\0\0\0\x54\x5\x1\0\x54\x5\0\0\xD0\0\0\0\x3\0\0\0\x14\0\0\0\x4\0\0\0\b\0\0\0\x57\0\0\0\x1\0\0\0\x6\0\0\0\x24\x6\x1\0\x24\x6\0\0\f\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x52\0\0\0\x1\0\0\0\x6\0\0\0\x30\x6\x1\0\x30\x6\0\0\x4C\x1\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x5D\0\0\0\x1\0\0\0\x6\0\0\0\x7C\a\x1\0\x7C\a\0\0\x80\a\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x63\0\0\0\x1\0\0\0\x6\0\0\0\xFC\xE\x1\0\xFC">>$F +printf "\xE\0\0\b\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x69\0\0\0\x1\0\0\0\x2\0\0\0\x4\xF\x1\0\x4\xF\0\0\x41\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x71\0\0\0\x1\0\0\x70\x82\0\0\0\x48\xF\x1\0\x48\xF\0\0\b\0\0\0\v\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x7C\0\0\0\x1\0\0\0\x2\0\0\0\x50\xF\x1\0\x50\xF\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x86\0\0\0\xE\0\0\0\x3\0\0\0\f\x1F\x2\0\f\x1F\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x92\0\0\0\xF\0\0\0\x3\0\0\0\x10\x1F\x2\0\x10\x1F\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x9E\0\0\0\x1\0\0\0\x3\0\0\0\x14\x1F\x2\0\x14\x1F\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\xA3\0\0\0\x6\0\0\0\x3\0\0\0\x18\x1F\x2\0\x18\x1F\0\0\xE8\0\0\0\x4\0\0\0\0\0\0\0\x4">>$F +printf "\0\0\0\b\0\0\0\xAC\0\0\0\x1\0\0\0\x3\0\0\0\0\x20\x2\0\0\x20\0\0\x78\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\xB1\0\0\0\x1\0\0\0\x3\0\0\0\x78\x20\x2\0\x78\x20\0\0\b\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\xB7\0\0\0\b\0\0\0\x3\0\0\0\x80\x20\x2\0\x80\x20\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\xBC\0\0\0\x3\0\0\x70\0\0\0\0\0\0\0\0\x80\x20\0\0\x32\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x1\0\0\0\x3\0\0\0\0\0\0\0\0\0\0\0\xB2\x20\0\0\xCC\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0">>$F + diff --git a/frontend/apps/site/public/uget/uget.arm-hisiv300-linux.sh b/frontend/apps/site/public/uget/uget.arm-hisiv300-linux.sh new file mode 100644 index 00000000..61447d7c --- /dev/null +++ b/frontend/apps/site/public/uget/uget.arm-hisiv300-linux.sh @@ -0,0 +1,26 @@ +cd /tmp;F=uget;true>$F;chmod +x $F +printf "\x7F\x45\x4C\x46\x1\x1\x1\0\0\0\0\0\0\0\0\0\x2\0\x28\0\x1\0\0\0\xC8\x88\0\0\x34\0\0\0\b\x12\0\0\x2\x2\0\x5\x34\0\x20\0\x6\0\x28\0\x15\0\x14\0\x6\0\0\0\x34\0\0\0\x34\x80\0\0\x34\x80\0\0\xC0\0\0\0\xC0\0\0\0\x5\0\0\0\x4\0\0\0\x3\0\0\0\xF4\0\0\0\xF4\x80\0\0\xF4\x80\0\0\x14\0\0\0\x14\0\0\0\x4\0\0\0\x1\0\0\0\x1\0\0\0\0\0\0\0\0\x80\0\0\0\x80\0\0\x48\xF\0\0\x48\xF\0\0\x5\0\0\0\0\x80\0\0\x1\0\0\0\0\x10\0\0\0\x10\x1\0\0\x10\x1\0\x44\x1\0\0\x60\x1\0\0\x6\0\0\0\0\x80\0\0\x2\0\0\0\f\x10\0\0\f\x10\x1\0\f\x10\x1\0\xB8\0\0\0\xB8\0\0\0\x6\0\0\0\x4\0\0\0\x51\xE5\x74\x64\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x6\0\0\0\x10\0\0\0\x2F\x6C\x69\x62\x2F\x6C\x64\x2D\x75\x43\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x30">$F +printf "\0\x11\0\0\0\x24\0\0\0\x11\0\0\0\x1E\0\0\0\x15\0\0\0"\0\0\0\x20\0\0\0\xE\0\0\0\x23\0\0\0\x14\0\0\0\x6\0\0\0\x21\0\0\0\f\0\0\0\x1C\0\0\0\x1\0\0\0\x1F\0\0\0\x1A\0\0\0\x17\0\0\0\x1D\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x5\0\0\0\a\0\0\0\0\0\0\0\t\0\0\0\v\0\0\0\b\0\0\0\0\0\0\0\x2\0\0\0\n\0\0\0\0\0\0\0\0\0\0\0\r\0\0\0\0\0\0\0\x13\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xF\0\0\0\x18\0\0\0\0\0\0\0\0\0\0\0\x19\0\0\0\x16\0\0\0\0\0\0\0\x10\0\0\0\x1B\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xA8\0\0\0\x4C\x86\0\0\0\0\0\0\x12\0\0\0\x53\0\0\0\x58\x86\0\0\0\0\0\0\x12\0\0\0\x65\0\0\0\x64\x86\0\0\0\0\0\0\x12\0\0\0\x3E\0\0\0\x70\x86\0\0\0\0\0\0\x12\0\0\0\xE5\0\0\0">>$F +printf "\x60\x11\x1\0\0\0\0\0\x10\0\x12\0\x7E\0\0\0\x7C\x86\0\0\0\0\0\0\x12\0\0\0\xD6\0\0\0\x44\x11\x1\0\0\0\0\0\x10\0\x12\0\xA1\0\0\0\x88\x86\0\0\0\0\0\0\x12\0\0\0\x6D\0\0\0\x94\x86\0\0\0\0\0\0\x12\0\0\0\x58\0\0\0\xA0\x86\0\0\0\0\0\0\x12\0\0\0\xBD\0\0\0\xAC\x86\0\0\0\0\0\0\x12\0\0\0\x38\0\0\0\xB8\x86\0\0\0\0\0\0\x12\0\0\0\xFD\0\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\xE4\0\0\0\x60\x11\x1\0\0\0\0\0\x10\0\x12\0\x79\0\0\0\xD0\x86\0\0\0\0\0\0\x12\0\0\0\xCF\0\0\0\xC8\x88\0\0\x50\0\0\0\x12\0\b\0\xB6\0\0\0\xDC\x86\0\0\0\0\0\0\x12\0\0\0\x93\0\0\0\xE8\x86\0\0\0\0\0\0\x12\0\0\0\x26\0\0\0\xF4\x86\0\0\0\0\0\0\x12\0\0\0\x72\0\0\0\0\x87\0\0\0\0\0\0\x12\0\0\0\x4B\0\0\0\f\x87\0\0\0\0\0\0\x12\0\0\0\xAF\0\0\0\x18\x87\0">>$F +printf "\0\0\0\0\0\x12\0\0\0\x85\0\0\0\x24\x87\0\0\0\0\0\0\x12\0\0\0\xCA\0\0\0\x44\x11\x1\0\0\0\0\0\x10\0\x12\0\x31\0\0\0\x30\x87\0\0\0\0\0\0\x12\0\0\0\xF0\0\0\0\x60\x11\x1\0\0\0\0\0\x10\0\x12\0\x9A\0\0\0\x3C\x87\0\0\0\0\0\0\x12\0\0\0\x12\0\0\0\x48\x87\0\0\0\0\0\0\x12\0\0\0\x1A\0\0\0\x54\x87\0\0\0\0\0\0\x12\0\0\0\xC3\0\0\0\x44\x11\x1\0\0\0\0\0\x10\0\x11\0\xF8\0\0\0\x60\x11\x1\0\0\0\0\0\x10\0\x12\0\v\0\0\0\x60\x87\0\0\0\0\0\0\x12\0\0\0\x15\x1\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\x2B\0\0\0\x78\x87\0\0\0\0\0\0\x12\0\0\0\x45\0\0\0\x84\x87\0\0\0\0\0\0\x12\0\0\0\0\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x30\0\x73\x74\x72\x6C\x65\x6E\0\x6D\x6B\x73\x74\x65\x6D\x70\0\x67\x65\x74\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x66">>$F +printf "\x6F\x72\x6B\0\x63\x6C\x6F\x73\x65\0\x6D\x65\x6D\x73\x65\x74\0\x77\x72\x69\x74\x65\0\x66\x63\x68\x6D\x6F\x64\0\x72\x61\x69\x73\x65\0\x73\x74\x72\x6E\x63\x61\x74\0\x72\x65\x63\x76\0\x66\x72\x65\x65\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x63\x6F\x6E\x6E\x65\x63\x74\0\x73\x65\x6E\x64\0\x73\x73\x63\x61\x6E\x66\0\x77\x61\x69\x74\0\x6D\x65\x6D\x63\x70\x79\0\x5F\x5F\x75\x43\x6C\x69\x62\x63\x5F\x6D\x61\x69\x6E\0\x75\x6E\x6C\x69\x6E\x6B\0\x73\x74\x72\x63\x6D\x70\0\x73\x6F\x63\x6B\x65\x74\0\x73\x74\x72\x63\x70\x79\0\x65\x78\x65\x63\x6C\x70\0\x73\x74\x72\x73\x74\x72\0\x61\x62\x6F\x72\x74\0\x5F\x65\x64\x61\x74\x61\0\x5F\x5F\x62\x73\x73\x5F\x73\x74\x61\x72\x74\0\x5F\x5F\x62\x73\x73\x5F\x73\x74\x61\x72\x74">>$F +printf "\x5F\x5F\0\x5F\x5F\x62\x73\x73\x5F\x65\x6E\x64\x5F\x5F\0\x5F\x5F\x65\x6E\x64\x5F\x5F\0\x5F\x65\x6E\x64\0\x5F\x5F\x64\x65\x72\x65\x67\x69\x73\x74\x65\x72\x5F\x66\x72\x61\x6D\x65\x5F\x69\x6E\x66\x6F\0\x5F\x5F\x72\x65\x67\x69\x73\x74\x65\x72\x5F\x66\x72\x61\x6D\x65\x5F\x69\x6E\x66\x6F\0\0\xD0\x10\x1\0\x16\x1\0\0\xD4\x10\x1\0\x16\x2\0\0\xD8\x10\x1\0\x16\x3\0\0\xDC\x10\x1\0\x16\x4\0\0\xE0\x10\x1\0\x16\x6\0\0\xE4\x10\x1\0\x16\b\0\0\xE8\x10\x1\0\x16\t\0\0\xEC\x10\x1\0\x16\n\0\0\xF0\x10\x1\0\x16\v\0\0\xF4\x10\x1\0\x16\f\0\0\xF8\x10\x1\0\x16\r\0\0\xFC\x10\x1\0\x16\xF\0\0\0\x11\x1\0\x16\x11\0\0\x4\x11\x1\0\x16\x12\0\0\b\x11\x1\0\x16\x13\0\0\f\x11\x1\0\x16\x14\0\0\x10\x11\x1\0\x16\x15\0\0\x14\x11">>$F +printf "\x1\0\x16\x16\0\0\x18\x11\x1\0\x16\x17\0\0\x1C\x11\x1\0\x16\x19\0\0\x20\x11\x1\0\x16\x1B\0\0\x24\x11\x1\0\x16\x1C\0\0\x28\x11\x1\0\x16\x1D\0\0\x2C\x11\x1\0\x16\x20\0\0\x30\x11\x1\0\x16\x21\0\0\x34\x11\x1\0\x16"\0\0\x38\x11\x1\0\x16\x23\0\0\r\xC0\xA0\xE1\xF0\xDF\x2D\xE9\x4\xB0\x4C\xE2\xF0\xAF\x1B\xE9\x4\xE0\x2D\xE5\x4\xE0\x9F\xE5\xE\xE0\x8F\xE0\b\xF0\xBE\xE5\x7C\x8A\0\0\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x7C\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x74\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x6C\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x64\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x5C\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x54\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x4C\xFA\xBC\xE5\0\xC6\x8F\xE2">>$F +printf "\b\xCA\x8C\xE2\x44\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x3C\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x34\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x2C\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x24\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x1C\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x14\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\f\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\x4\xFA\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xFC\xF9\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xF4\xF9\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xEC\xF9\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xE4\xF9\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xDC\xF9\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xD4\xF9\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xCC\xF9\xBC\xE5\0\xC6">>$F +printf "\x8F\xE2\b\xCA\x8C\xE2\xC4\xF9\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xBC\xF9\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xB4\xF9\xBC\xE5\0\xC6\x8F\xE2\b\xCA\x8C\xE2\xAC\xF9\xBC\xE5\x1\0\x50\xE3\x42\0\0\xDA\x3\0\x50\xE3\xF0\x40\x2D\xE9\x1\x30\xA0\x13\x1C\xD0\x4D\xE2\x1\x50\xA0\xE1\0\x40\xA0\x13\x6\0\0\x1A\x4\x1\x9F\xE5\x4\x10\x91\xE5\xDE\xFF\xFF\xEB\0\0\x50\xE3\x1\x40\xA0\x3\x2\x30\xA0\x3\x36\0\0\x1A\x3\x71\x95\xE7\a\x20\xA0\xE1\x2\x60\xA0\xE1\x1\x30\xD2\xE4\0\0\x53\xE3\x4\0\0\n\x2F\0\x53\xE3\xF9\xFF\xFF\x1A\0\x30\xA0\xE3\x1\x30\xC6\xE4\0\0\0\xEA\x3\x60\xA0\xE1\b\0\x8D\xE2\xB8\x10\x9F\xE5\x10\x20\xA0\xE3\x9A\xFF\xFF\xEB\0\0\x54\xE3\x1\x50\xA0\x3\x2\0\0\n\b\0\x8D\xE2\xC8\xFF\xFF\xEB\0\x50\xA0\xE1">>$F +printf "\x6\x20\xA0\xE1\x5\0\xA0\xE1\a\x10\xA0\xE1\x88\0\0\xEB\0\x60\x50\xE2\x13\0\0\x1A\0\0\x54\xE3\x4\0\xA0\x1\x1A\0\0\n\x5\x1D\xA0\xE3\x5\0\xA0\xE1\x85\xFF\xFF\xEB\x5\0\xA0\xE1\xC5\xFF\xFF\xEB\xA3\xFF\xFF\xEB\0\x20\x50\xE2\x3\0\0\n\x4\0\x8D\xE2\x96\xFF\xFF\xEB\x5\x60\xDD\xE5\x6\0\0\xEA\b\0\x8D\xE2\0\x10\xA0\xE1\xA3\xFF\xFF\xEB\x1\0\xA0\xE3\t\0\0\xEA\0\0\x54\xE3\x6\0\0\n\b\0\x8D\xE2\x91\xFF\xFF\xEB\x3\0\0\xEA\x6\0\xA0\xE3\x1E\xFF\x2F\xE1\x6\0\xA0\xE3\0\0\0\xEA\x6\0\xA0\xE1\x1C\xD0\x8D\xE2\xF0\x80\xBD\xE8\x2D\x8F\0\0\x31\x8F\0\0\0\xB0\xA0\xE3\0\xE0\xA0\xE3\x4\x10\x9D\xE4\r\x20\xA0\xE1\x4\x20\x2D\xE5\x4\0\x2D\xE5\x10\xC0\x9F\xE5\x4\xC0\x2D\xE5\f\0\x9F\xE5\f\x30\x9F\xE5\x8B\xFF\xFF\xEA\x6C\xFF">>$F +printf "\xFF\xEB\xF4\x8E\0\0\x90\x87\0\0\x28\x86\0\0\x1C\x30\x9F\xE5\x1C\0\x9F\xE5\x3\x30\x60\xE0\x6\0\x53\xE3\x1E\xFF\x2F\x91\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x47\x11\x1\0\x44\x11\x1\0\0\0\0\0\x24\x30\x9F\xE5\x24\0\x9F\xE5\x3\x30\x60\xE0\x43\x31\xA0\xE1\xA3\x3F\x83\xE0\xC3\x10\xB0\xE1\x1E\xFF\x2F\x1\x10\x20\x9F\xE5\0\0\x52\xE3\x1E\xFF\x2F\x1\x12\xFF\x2F\xE1\x44\x11\x1\0\x44\x11\x1\0\0\0\0\0\x10\x40\x2D\xE9\x2C\x40\x9F\xE5\0\x30\xD4\xE5\0\0\x53\xE3\x10\x80\xBD\x18\xDF\xFF\xFF\xEB\x1C\x30\x9F\xE5\0\0\x53\xE3\x1\0\0\n\x14\0\x9F\xE5\x4A\xFF\xFF\xEB\x1\x30\xA0\xE3\0\x30\xC4\xE5\x10\x80\xBD\xE8\x44\x11\x1\0\0\0\0\0\x44\x8F\0\0\b\x40\x2D\xE9\x38\x30\x9F\xE5\0\0\x53\xE3\x2\0">>$F +printf "\0\n\x30\0\x9F\xE5\x30\x10\x9F\xE5\x67\xFF\xFF\xEB\x2C\0\x9F\xE5\0\x30\x90\xE5\0\0\x53\xE3\x3\0\0\n\x20\x30\x9F\xE5\0\0\x53\xE3\0\0\0\n\x33\xFF\x2F\xE1\b\x40\xBD\xE8\xCF\xFF\xFF\xEA\0\0\0\0\x44\x8F\0\0\x48\x11\x1\0\b\x10\x1\0\0\0\0\0\x4\xE0\x2D\xE5\x2\xDA\x4D\xE2\x14\xD0\x4D\xE2\x1\x3A\x8D\xE2\x10\x20\x8D\xE2\x10\x30\x83\xE2\0\x30\x8D\xE5\x28\x10\x9F\xE5\x4\x30\x42\xE2\x33\xFF\xFF\xEB\x1\0\x50\xE3\x2\x2A\x8D\xC2\x18\x30\x9F\xC5\x10\x20\x82\xC2\0\0\xE0\xD3\x3\0\x92\xC7\x2\xDA\x8D\xE2\x14\xD0\x8D\xE2\x4\xF0\x9D\xE4\x4\x8F\0\0\xFC\xDF\xFF\xFF\xF0\x47\x2D\xE9\x1\xDA\x4D\xE2\x28\xD0\x4D\xE2\x1\x90\xA0\xE1\0\x80\xA0\xE1\0\x10\xA0\xE3\x2\xA0\xA0\xE1\x28\x40\x8D\xE2\x20\x20\xA0\xE3\b\0\x8D\xE2">>$F +printf "\x29\xFF\xFF\xEB\x1\x70\xA0\xE3\t\0\xA0\xE1\xF8\x11\x9F\xE5\b\x20\x8D\xE2\x24\x30\x44\xE2\x10\x70\x8D\xE5\x2B\xFF\xFF\xEB\0\0\x50\xE3\x3\0\xA0\x13\x74\0\0\x1A\xDC\x31\x9F\xE5\x1\x2A\x8D\xE2\x28\x20\x82\xE2\x3\x50\x92\xE7\0\x60\xE0\xE3\0\0\x55\xE3\x10\0\0\n\a\0\x95\xE9\xEC\xFE\xFF\xEB\0\x60\x50\xE2\t\0\0\xBA\x14\x10\x95\xE5\x10\x20\x95\xE5\xDE\xFE\xFF\xEB\0\0\x50\xE3\a\0\0\xAA\x6\0\xA0\xE1\x1F\xFF\xFF\xEB\0\x60\xE0\xE3\x4\x70\xA0\xE3\0\0\0\xEA\x2\x70\xA0\xE3\x1C\x50\x95\xE5\xEC\xFF\xFF\xEA\x7C\x31\x9F\xE5\x1\x2A\x8D\xE2\x28\x20\x82\xE2\x3\0\x92\xE7\xDE\xFE\xFF\xEB\0\0\x56\xE3\a\0\xA0\xB1\x54\0\0\xBA\x4\0\xA0\xE1\x5C\x11\x9F\xE5\xC3\xFE\xFF\xEB\0\0\x5A\xE3\x6\0\0\n\x4\0\xA0\xE1\x4\xFF\xFF">>$F +printf "\xEB\n\x10\xA0\xE1\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xEA\xFE\xFF\xEB\x4\0\xA0\xE1\xFD\xFE\xFF\xEB\x2C\x11\x9F\xE5\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xE3\xFE\xFF\xEB\x4\0\xA0\xE1\xF6\xFE\xFF\xEB\t\x10\xA0\xE1\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xDC\xFE\xFF\xEB\x4\0\xA0\xE1\xEF\xFE\xFF\xEB\xF8\x10\x9F\xE5\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xD5\xFE\xFF\xEB\x4\0\xA0\xE1\xE8\xFE\xFF\xEB\x4\x10\xA0\xE1\0\x30\xA0\xE3\0\x50\xA0\xE1\x5\x20\xA0\xE1\x6\0\xA0\xE1\xAF\xFE\xFF\xEB\x5\0\x50\xE1\x1\x70\xA0\x3\x27\0\0\x1A\x6\0\xA0\xE1\x4\x10\xA0\xE1\x1\x2A\xA0\xE3\0\x30\xA0\xE3\x98\xFE\xFF\xEB\0\x50\x50\xE2\x1E\0\0\n\0\0\x57\xE3\x16\0\0\n\x4\0\xA0\xE1\x90\x10\x9F\xE5">>$F +printf "\xB2\xFE\xFF\xEB\0\x90\x50\xE2\xF1\xFF\xFF\n\x4\0\xA0\xE1\x79\xFF\xFF\xEB\xC8\x30\x40\xE2\x63\0\x53\xE3\0\x70\xA0\xE1\a\0\0\x9A\x64\x10\xA0\xE3\x1A\0\0\xEB\n\x10\xA0\xE3\x91\0\x4\xE0\a\0\xA0\xE1\x9E\0\0\xEB\x1\0\x84\xE0\f\0\0\xEA\x4\x10\x89\xE2\x4\x30\x61\xE0\x3\x50\x85\xE0\0\0\0\xEA\x4\x10\xA0\xE1\b\0\xA0\xE1\x5\x20\xA0\xE1\x91\xFE\xFF\xEB\0\x70\xA0\xE3\xD9\xFF\xFF\xEA\x5\0\xA0\xE1\0\0\0\xEA\x5\0\xA0\xE3\x1\xDA\x8D\xE2\x28\xD0\x8D\xE2\xF0\x87\xBD\xE8\r\x8F\0\0\xDC\xEF\xFF\xFF\x10\x8F\0\0\x16\x8F\0\0\x28\x8F\0\0\0\0\x51\xE3\x81\0\0\n\x1\xC0\x20\xE0\0\x10\x61\x42\x1\x20\x51\xE2\x70\0\0\n\0\x30\xB0\xE1\0\x30\x60\x42\x1\0\x53\xE1\x6F\0\0\x9A\x2\0\x11\xE1\x71\0\0\n\x13\x2F\x6F\xE1\x11\xF\x6F">>$F +printf "\xE1\x2\x20\x40\xE0\x1F\x20\x72\xE2\x82\x20\x82\x10\0\0\xA0\xE3\x2\xF1\x8F\x10\0\0\xA0\xE1\x81\xF\x53\xE1\0\0\xA0\xE0\x81\x3F\x43\x20\x1\xF\x53\xE1\0\0\xA0\xE0\x1\x3F\x43\x20\x81\xE\x53\xE1\0\0\xA0\xE0\x81\x3E\x43\x20\x1\xE\x53\xE1\0\0\xA0\xE0\x1\x3E\x43\x20\x81\r\x53\xE1\0\0\xA0\xE0\x81\x3D\x43\x20\x1\r\x53\xE1\0\0\xA0\xE0\x1\x3D\x43\x20\x81\f\x53\xE1\0\0\xA0\xE0\x81\x3C\x43\x20\x1\f\x53\xE1\0\0\xA0\xE0\x1\x3C\x43\x20\x81\v\x53\xE1\0\0\xA0\xE0\x81\x3B\x43\x20\x1\v\x53\xE1\0\0\xA0\xE0\x1\x3B\x43\x20\x81\n\x53\xE1\0\0\xA0\xE0\x81\x3A\x43\x20\x1\n\x53\xE1\0\0\xA0\xE0\x1\x3A\x43\x20\x81\t\x53\xE1\0\0\xA0\xE0\x81\x39\x43\x20\x1\t\x53\xE1\0\0\xA0\xE0\x1\x39\x43\x20\x81\b\x53\xE1\0\0\xA0\xE0">>$F +printf "\x81\x38\x43\x20\x1\b\x53\xE1\0\0\xA0\xE0\x1\x38\x43\x20\x81\a\x53\xE1\0\0\xA0\xE0\x81\x37\x43\x20\x1\a\x53\xE1\0\0\xA0\xE0\x1\x37\x43\x20\x81\x6\x53\xE1\0\0\xA0\xE0\x81\x36\x43\x20\x1\x6\x53\xE1\0\0\xA0\xE0\x1\x36\x43\x20\x81\x5\x53\xE1\0\0\xA0\xE0\x81\x35\x43\x20\x1\x5\x53\xE1\0\0\xA0\xE0\x1\x35\x43\x20\x81\x4\x53\xE1\0\0\xA0\xE0\x81\x34\x43\x20\x1\x4\x53\xE1\0\0\xA0\xE0\x1\x34\x43\x20\x81\x3\x53\xE1\0\0\xA0\xE0\x81\x33\x43\x20\x1\x3\x53\xE1\0\0\xA0\xE0\x1\x33\x43\x20\x81\x2\x53\xE1\0\0\xA0\xE0\x81\x32\x43\x20\x1\x2\x53\xE1\0\0\xA0\xE0\x1\x32\x43\x20\x81\x1\x53\xE1\0\0\xA0\xE0\x81\x31\x43\x20\x1\x1\x53\xE1\0\0\xA0\xE0\x1\x31\x43\x20\x81\0\x53\xE1\0\0\xA0\xE0\x81\x30\x43\x20\x1\0\x53">>$F +printf "\xE1\0\0\xA0\xE0\x1\x30\x43\x20\0\0\x5C\xE3\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x3C\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\xA0\x33\xCC\xF\xA0\x1\x1\0\x80\x3\x1E\xFF\x2F\xE1\x11\x2F\x6F\xE1\x1F\x20\x62\xE2\0\0\x5C\xE3\x33\x2\xA0\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x50\xE3\x2\x1\xE0\xC3\x2\x1\xA0\xB3\a\0\0\xEA\0\0\x51\xE3\xF9\xFF\xFF\n\x3\x40\x2D\xE9\x75\xFF\xFF\xEB\x6\x40\xBD\xE8\x92\0\x3\xE0\x3\x10\x41\xE0\x1E\xFF\x2F\xE1\x2\x40\x2D\xE9\b\0\xA0\xE3\x24\xFE\xFF\xEB\x2\x80\xBD\xE8\r\xC0\xA0\xE1\xF0\xDF\x2D\xE9\x4\xB0\x4C\xE2\xF0\xAF\x1B\xE9\x25\x73\x20\x25\x64\x20\x25\x73\0\x38\x30\0\x47\x45\x54\x20\x2F\0\x20\x48\x54\x54\x50\x2F\x31\x2E\x30\r\n\x48\x6F\x73\x74\x3A\x20\0\r\n\r\n\0\x72\x75\x6E\0">>$F +printf "\x2F\x74\x6D\x70\x2F\x75\x67\x65\x74\x58\x58\x58\x58\x58\x58\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xB0\x89\0\0\x6C\x89\0\0\0\0\0\0\x1\0\0\0\x1\0\0\0\f\0\0\0\x28\x86\0\0\r\0\0\0\xF4\x8E\0\0\x19\0\0\0\0\x10\x1\0\x1B\0\0\0\x4\0\0\0\x1A\0\0\0\x4\x10\x1\0\x1C\0\0\0\x4\0\0\0\x4\0\0\0\b\x81\0\0\x5\0\0\0\x24\x84\0\0\x6\0\0\0\xE4\x81\0\0\n\0\0\0\x2B\x1\0">>$F +printf "\0\v\0\0\0\x10\0\0\0\x15\0\0\0\0\0\0\0\x3\0\0\0\xC4\x10\x1\0\x2\0\0\0\xD8\0\0\0\x14\0\0\0\x11\0\0\0\x17\0\0\0\x50\x85\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\f\x10\x1\0\0\0\0\0\0\0\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\x38\x86\0\0\0\0\0\0\0\0\0\0\x41\x26\0\0\0\x61\x65\x61\x62\x69\0\x1\x1C\0\0\0\x5\x35\x54\x45\0\x6\x5\b\x1\t\x1\x12\x4\x14\x1\x15\x1\x17\x3">>$F +printf "\x18\x1\x1A\x2\0\x2E\x73\x68\x73\x74\x72\x74\x61\x62\0\x2E\x69\x6E\x74\x65\x72\x70\0\x2E\x68\x61\x73\x68\0\x2E\x64\x79\x6E\x73\x79\x6D\0\x2E\x64\x79\x6E\x73\x74\x72\0\x2E\x72\x65\x6C\x2E\x70\x6C\x74\0\x2E\x69\x6E\x69\x74\0\x2E\x74\x65\x78\x74\0\x2E\x66\x69\x6E\x69\0\x2E\x72\x6F\x64\x61\x74\x61\0\x2E\x65\x68\x5F\x66\x72\x61\x6D\x65\0\x2E\x69\x6E\x69\x74\x5F\x61\x72\x72\x61\x79\0\x2E\x66\x69\x6E\x69\x5F\x61\x72\x72\x61\x79\0\x2E\x6A\x63\x72\0\x2E\x64\x79\x6E\x61\x6D\x69\x63\0\x2E\x67\x6F\x74\0\x2E\x64\x61\x74\x61\0\x2E\x62\x73\x73\0\x2E\x41\x52\x4D\x2E\x61\x74\x74\x72\x69\x62\x75\x74\x65\x73\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\v\0\0\0\x1\0">>$F +printf "\0\0\x2\0\0\0\xF4\x80\0\0\xF4\0\0\0\x14\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x13\0\0\0\x5\0\0\0\x2\0\0\0\b\x81\0\0\b\x1\0\0\xDC\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x19\0\0\0\v\0\0\0\x2\0\0\0\xE4\x81\0\0\xE4\x1\0\0\x40\x2\0\0\x4\0\0\0\x1\0\0\0\x4\0\0\0\x10\0\0\0\x21\0\0\0\x3\0\0\0\x2\0\0\0\x24\x84\0\0\x24\x4\0\0\x2B\x1\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x29\0\0\0\t\0\0\0\x2\0\0\0\x50\x85\0\0\x50\x5\0\0\xD8\0\0\0\x3\0\0\0\a\0\0\0\x4\0\0\0\b\0\0\0\x32\0\0\0\x1\0\0\0\x6\0\0\0\x28\x86\0\0\x28\x6\0\0\x10\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x2D\0\0\0\x1\0\0\0\x6\0\0\0\x38\x86\0\0\x38\x6\0\0\x58\x1\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x38\0\0\0\x1\0\0\0\x6\0\0\0\x90\x87\0\0\x90\a">>$F +printf "\0\0\x64\a\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x3E\0\0\0\x1\0\0\0\x6\0\0\0\xF4\x8E\0\0\xF4\xE\0\0\x10\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x44\0\0\0\x1\0\0\0\x32\0\0\0\x4\x8F\0\0\x4\xF\0\0\x3D\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\x1\0\0\0\x4C\0\0\0\x1\0\0\0\x2\0\0\0\x44\x8F\0\0\x44\xF\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x56\0\0\0\xE\0\0\0\x3\0\0\0\0\x10\x1\0\0\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x62\0\0\0\xF\0\0\0\x3\0\0\0\x4\x10\x1\0\x4\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x6E\0\0\0\x1\0\0\0\x3\0\0\0\b\x10\x1\0\b\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x73\0\0\0\x6\0\0\0\x3\0\0\0\f\x10\x1\0\f\x10\0\0\xB8\0\0\0\x4\0\0\0\0\0\0\0\x4\0\0\0\b\0\0">>$F +printf "\0\x7C\0\0\0\x1\0\0\0\x3\0\0\0\xC4\x10\x1\0\xC4\x10\0\0\x78\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x81\0\0\0\x1\0\0\0\x3\0\0\0\x3C\x11\x1\0\x3C\x11\0\0\b\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x87\0\0\0\b\0\0\0\x3\0\0\0\x44\x11\x1\0\x44\x11\0\0\x1C\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x8C\0\0\0\x3\0\0\x70\0\0\0\0\0\0\0\0\x44\x11\0\0\x27\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x1\0\0\0\x3\0\0\0\0\0\0\0\0\0\0\0\x6B\x11\0\0\x9C\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0">>$F + diff --git a/frontend/apps/site/public/uget/uget.arm-hisiv500-linux.sh b/frontend/apps/site/public/uget/uget.arm-hisiv500-linux.sh new file mode 100644 index 00000000..d9ae70a4 --- /dev/null +++ b/frontend/apps/site/public/uget/uget.arm-hisiv500-linux.sh @@ -0,0 +1,26 @@ +cd /tmp;F=uget;true>$F;chmod +x $F +printf "\x7F\x45\x4C\x46\x1\x1\x1\0\0\0\0\0\0\0\0\0\x2\0\x28\0\x1\0\0\0\xCC\b\x1\0\x34\0\0\0\x10\x12\0\0\x2\x2\0\x5\x34\0\x20\0\x6\0\x28\0\x15\0\x14\0\x6\0\0\0\x34\0\0\0\x34\0\x1\0\x34\0\x1\0\xC0\0\0\0\xC0\0\0\0\x5\0\0\0\x4\0\0\0\x3\0\0\0\xF4\0\0\0\xF4\0\x1\0\xF4\0\x1\0\x14\0\0\0\x14\0\0\0\x4\0\0\0\x1\0\0\0\x1\0\0\0\0\0\0\0\0\0\x1\0\0\0\x1\0\x24\xF\0\0\x24\xF\0\0\x5\0\0\0\0\0\x1\0\x1\0\0\0\0\x10\0\0\0\x10\x2\0\0\x10\x2\0\x44\x1\0\0\x60\x1\0\0\x6\0\0\0\0\0\x1\0\x2\0\0\0\f\x10\0\0\f\x10\x2\0\f\x10\x2\0\xB8\0\0\0\xB8\0\0\0\x6\0\0\0\x4\0\0\0\x51\xE5\x74\x64\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x6\0\0\0\x10\0\0\0\x2F\x6C\x69\x62\x2F\x6C\x64\x2D\x75\x43\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x30\0\x11\0">$F +printf "\0\0\x24\0\0\0\x11\0\0\0\x1E\0\0\0\x15\0\0\0"\0\0\0\x20\0\0\0\xE\0\0\0\x23\0\0\0\x14\0\0\0\x6\0\0\0\x21\0\0\0\f\0\0\0\x1C\0\0\0\x1\0\0\0\x1F\0\0\0\x1A\0\0\0\x17\0\0\0\x1D\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x5\0\0\0\a\0\0\0\0\0\0\0\t\0\0\0\v\0\0\0\b\0\0\0\0\0\0\0\x2\0\0\0\n\0\0\0\0\0\0\0\0\0\0\0\r\0\0\0\0\0\0\0\x13\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xF\0\0\0\x18\0\0\0\0\0\0\0\0\0\0\0\x19\0\0\0\x16\0\0\0\0\0\0\0\x10\0\0\0\x1B\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xA8\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x53\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x65\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x3E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xE5\0\0\0\x60\x11\x2\0\0\0\0\0\x10">>$F +printf "\0\x12\0\x7E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xD6\0\0\0\x44\x11\x2\0\0\0\0\0\x10\0\x12\0\xA1\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x6D\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x58\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xBD\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x38\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xFD\0\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\xE4\0\0\0\x60\x11\x2\0\0\0\0\0\x10\0\x12\0\x79\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xCF\0\0\0\xCC\b\x1\0\x50\0\0\0\x12\0\b\0\xB6\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x93\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x26\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x72\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x4B\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xAF\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x85\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xCA\0\0\0\x44\x11">>$F +printf "\x2\0\0\0\0\0\x10\0\x12\0\x31\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xF0\0\0\0\x60\x11\x2\0\0\0\0\0\x10\0\x12\0\x9A\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x1A\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xC3\0\0\0\x44\x11\x2\0\0\0\0\0\x10\0\x11\0\xF8\0\0\0\x60\x11\x2\0\0\0\0\0\x10\0\x12\0\v\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x15\x1\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\x2B\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x45\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\0\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x30\0\x73\x74\x72\x6C\x65\x6E\0\x6D\x6B\x73\x74\x65\x6D\x70\0\x67\x65\x74\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x66\x6F\x72\x6B\0\x63\x6C\x6F\x73\x65\0\x6D\x65\x6D\x73\x65\x74\0\x77\x72\x69\x74\x65\0\x66\x63\x68\x6D\x6F\x64">>$F +printf "\0\x72\x61\x69\x73\x65\0\x73\x74\x72\x6E\x63\x61\x74\0\x72\x65\x63\x76\0\x66\x72\x65\x65\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x63\x6F\x6E\x6E\x65\x63\x74\0\x73\x65\x6E\x64\0\x73\x73\x63\x61\x6E\x66\0\x77\x61\x69\x74\0\x6D\x65\x6D\x63\x70\x79\0\x5F\x5F\x75\x43\x6C\x69\x62\x63\x5F\x6D\x61\x69\x6E\0\x75\x6E\x6C\x69\x6E\x6B\0\x73\x74\x72\x63\x6D\x70\0\x73\x6F\x63\x6B\x65\x74\0\x73\x74\x72\x63\x70\x79\0\x65\x78\x65\x63\x6C\x70\0\x73\x74\x72\x73\x74\x72\0\x61\x62\x6F\x72\x74\0\x5F\x65\x64\x61\x74\x61\0\x5F\x5F\x62\x73\x73\x5F\x73\x74\x61\x72\x74\0\x5F\x5F\x62\x73\x73\x5F\x73\x74\x61\x72\x74\x5F\x5F\0\x5F\x5F\x62\x73\x73\x5F\x65\x6E\x64\x5F\x5F\0\x5F\x5F\x65\x6E\x64\x5F\x5F\0\x5F\x65\x6E\x64\0\x5F">>$F +printf "\x5F\x64\x65\x72\x65\x67\x69\x73\x74\x65\x72\x5F\x66\x72\x61\x6D\x65\x5F\x69\x6E\x66\x6F\0\x5F\x5F\x72\x65\x67\x69\x73\x74\x65\x72\x5F\x66\x72\x61\x6D\x65\x5F\x69\x6E\x66\x6F\0\0\xD0\x10\x2\0\x16\x1\0\0\xD4\x10\x2\0\x16\x2\0\0\xD8\x10\x2\0\x16\x3\0\0\xDC\x10\x2\0\x16\x4\0\0\xE0\x10\x2\0\x16\x6\0\0\xE4\x10\x2\0\x16\b\0\0\xE8\x10\x2\0\x16\t\0\0\xEC\x10\x2\0\x16\n\0\0\xF0\x10\x2\0\x16\v\0\0\xF4\x10\x2\0\x16\f\0\0\xF8\x10\x2\0\x16\r\0\0\xFC\x10\x2\0\x16\xF\0\0\0\x11\x2\0\x16\x11\0\0\x4\x11\x2\0\x16\x12\0\0\b\x11\x2\0\x16\x13\0\0\f\x11\x2\0\x16\x14\0\0\x10\x11\x2\0\x16\x15\0\0\x14\x11\x2\0\x16\x16\0\0\x18\x11\x2\0\x16\x17\0\0\x1C\x11\x2\0\x16\x19\0\0\x20\x11\x2\0\x16\x1B\0\0\x24\x11\x2\0\x16">>$F +printf "\x1C\0\0\x28\x11\x2\0\x16\x1D\0\0\x2C\x11\x2\0\x16\x20\0\0\x30\x11\x2\0\x16\x21\0\0\x34\x11\x2\0\x16"\0\0\x38\x11\x2\0\x16\x23\0\0\r\xC0\xA0\xE1\xF0\xDF\x2D\xE9\x4\xB0\x4C\xE2\xF0\xAF\x1B\xE9\x4\xE0\x2D\xE5\x4\xE0\x9F\xE5\xE\xE0\x8F\xE0\b\xF0\xBE\xE5\x7C\n\x1\0\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x7C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x74\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x6C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x64\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x5C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x54\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x4C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x44\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x3C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10">>$F +printf "\xCA\x8C\xE2\x34\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x2C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x24\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x1C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x14\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\f\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x4\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xFC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xF4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xEC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xE4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xDC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xD4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xCC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xC4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C">>$F +printf "\xE2\xBC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xB4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xAC\xF9\xBC\xE5\x1\0\x50\xE3\x43\0\0\xDA\x3\0\x50\xE3\xF0\x40\x2D\xE9\x1\x30\xA0\x13\x1C\xD0\x4D\xE2\x1\x40\xA0\xE1\0\x50\xA0\x13\x6\0\0\x1A\x4\x10\x91\xE5\x4\x1\x9F\xE5\xDE\xFF\xFF\xEB\0\0\x50\xE3\x1\x50\xA0\x3\x2\x30\xA0\x3\x37\0\0\x1A\x3\x71\x94\xE7\a\x30\xA0\xE1\x3\x10\xA0\xE1\x1\x20\xD3\xE4\0\0\x52\xE3\x5\0\0\n\x2F\0\x52\xE3\xF9\xFF\xFF\x1A\0\x30\xA0\xE3\x1\x40\x81\xE2\0\x30\xC1\xE5\0\0\0\xEA\x2\x40\xA0\xE1\x10\x20\xA0\xE3\xB8\x10\x9F\xE5\b\0\x8D\xE2\x99\xFF\xFF\xEB\0\0\x55\xE3\x1\x60\xA0\x3\x2\0\0\n\b\0\x8D\xE2\xC7\xFF\xFF\xEB\0\x60\xA0\xE1\x4\x20\xA0\xE1\a\x10\xA0\xE1\x6\0\xA0\xE1\x85">>$F +printf "\0\0\xEB\0\x40\x50\xE2\x13\0\0\x1A\0\0\x55\xE3\x5\0\xA0\x1\x1A\0\0\n\x5\x1D\xA0\xE3\x6\0\xA0\xE1\x84\xFF\xFF\xEB\x6\0\xA0\xE1\xC4\xFF\xFF\xEB\xA2\xFF\xFF\xEB\0\x20\x50\xE2\x3\0\0\n\x4\0\x8D\xE2\x95\xFF\xFF\xEB\x5\x40\xDD\xE5\x6\0\0\xEA\b\x10\x8D\xE2\x1\0\xA0\xE1\xA2\xFF\xFF\xEB\x1\0\xA0\xE3\t\0\0\xEA\0\0\x55\xE3\x6\0\0\n\b\0\x8D\xE2\x90\xFF\xFF\xEB\x3\0\0\xEA\x6\0\xA0\xE3\x1E\xFF\x2F\xE1\x6\0\xA0\xE3\0\0\0\xEA\x4\0\xA0\xE1\x1C\xD0\x8D\xE2\xF0\x80\xBD\xE8\t\xF\x1\0\r\xF\x1\0\0\xB0\xA0\xE3\0\xE0\xA0\xE3\x4\x10\x9D\xE4\r\x20\xA0\xE1\x4\x20\x2D\xE5\x4\0\x2D\xE5\x10\xC0\x9F\xE5\x4\xC0\x2D\xE5\f\0\x9F\xE5\f\x30\x9F\xE5\x8A\xFF\xFF\xEA\x6B\xFF\xFF\xEB\xD0\xE\x1\0\x90\a\x1\0\x28\x6\x1\0\x1C\x30">>$F +printf "\x9F\xE5\x1C\0\x9F\xE5\x3\x30\x60\xE0\x6\0\x53\xE3\x1E\xFF\x2F\x91\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x47\x11\x2\0\x44\x11\x2\0\0\0\0\0\x24\x10\x9F\xE5\x24\0\x9F\xE5\x1\x10\x60\xE0\x41\x11\xA0\xE1\xA1\x1F\x81\xE0\xC1\x10\xB0\xE1\x1E\xFF\x2F\x1\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x44\x11\x2\0\x44\x11\x2\0\0\0\0\0\x10\x40\x2D\xE9\x2C\x40\x9F\xE5\0\x30\xD4\xE5\0\0\x53\xE3\x10\x80\xBD\x18\xDF\xFF\xFF\xEB\x1C\x30\x9F\xE5\0\0\x53\xE3\x1\0\0\n\x14\0\x9F\xE5\x49\xFF\xFF\xEB\x1\x30\xA0\xE3\0\x30\xC4\xE5\x10\x80\xBD\xE8\x44\x11\x2\0\0\0\0\0\x20\xF\x1\0\x40\x30\x9F\xE5\x10\x40\x2D\xE9\0\0\x53\xE3\x2\0\0\n\x34\x10\x9F\xE5\x34\0\x9F\xE5\x66\xFF\xFF\xEB">>$F +printf "\x30\0\x9F\xE5\0\x30\x90\xE5\0\0\x53\xE3\x1\0\0\x1A\x10\x40\xBD\xE8\xD3\xFF\xFF\xEA\x1C\x30\x9F\xE5\0\0\x53\xE3\xFA\xFF\xFF\n\x33\xFF\x2F\xE1\xF8\xFF\xFF\xEA\0\0\0\0\x48\x11\x2\0\x20\xF\x1\0\b\x10\x2\0\0\0\0\0\x4\xE0\x2D\xE5\x2\xDA\x4D\xE2\x14\xD0\x4D\xE2\x1\x3A\x8D\xE2\x10\x30\x83\xE2\0\x30\x8D\xE5\x10\x20\x8D\xE2\f\x30\x8D\xE2\x18\x10\x9F\xE5\x31\xFF\xFF\xEB\x1\0\x50\xE3\0\0\xE0\xD3\f\0\x9D\xC5\x2\xDA\x8D\xE2\x14\xD0\x8D\xE2\x4\xF0\x9D\xE4\xE0\xE\x1\0\xF0\x47\x2D\xE9\x1\xDA\x4D\xE2\x28\xD0\x4D\xE2\x28\x40\x8D\xE2\x1\x90\xA0\xE1\0\x80\xA0\xE1\0\x10\xA0\xE3\x2\xA0\xA0\xE1\b\0\x8D\xE2\x20\x20\xA0\xE3\x2B\xFF\xFF\xEB\x1\x50\xA0\xE3\x24\x30\x44\xE2\b\x20\x8D\xE2\xDC\x11\x9F\xE5\t\0\xA0\xE1">>$F +printf "\x18\x50\x4\xE5\x2D\xFF\xFF\xEB\0\0\x50\xE3\x24\x60\x14\x5\0\x70\xE0\x3\x68\0\0\x1A\0\0\x56\xE3\x10\0\0\n\a\0\x96\xE9\xF2\xFE\xFF\xEB\0\x70\x50\xE2\t\0\0\xBA\x10\x20\x96\xE5\x14\x10\x96\xE5\xE4\xFE\xFF\xEB\0\0\x50\xE3\a\0\0\xAA\a\0\xA0\xE1\x25\xFF\xFF\xEB\0\x70\xE0\xE3\x4\x50\xA0\xE3\0\0\0\xEA\x2\x50\xA0\xE3\x1C\x60\x96\xE5\xEC\xFF\xFF\xEA\x24\0\x14\xE5\xE7\xFE\xFF\xEB\0\0\x57\xE3\x53\0\0\xBA\x64\x11\x9F\xE5\x4\0\xA0\xE1\xCD\xFE\xFF\xEB\0\0\x5A\xE3\x6\0\0\n\x4\0\xA0\xE1\xE\xFF\xFF\xEB\n\x10\xA0\xE1\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xF4\xFE\xFF\xEB\x4\0\xA0\xE1\a\xFF\xFF\xEB\x30\x11\x9F\xE5\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xED\xFE\xFF\xEB\x4\0\xA0\xE1\0\xFF\xFF\xEB">>$F +printf "\t\x10\xA0\xE1\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xE6\xFE\xFF\xEB\x4\0\xA0\xE1\xF9\xFE\xFF\xEB\xFC\x10\x9F\xE5\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xDF\xFE\xFF\xEB\x4\0\xA0\xE1\xF2\xFE\xFF\xEB\0\x30\xA0\xE3\x4\x10\xA0\xE1\0\x50\xA0\xE1\0\x20\xA0\xE1\a\0\xA0\xE1\xB9\xFE\xFF\xEB\x5\0\x50\xE1\x1\x90\xA0\x3\x29\0\0\x1A\0\x30\xA0\xE3\x1\x2A\xA0\xE3\x4\x10\xA0\xE1\a\0\xA0\xE1\xA2\xFE\xFF\xEB\0\x50\x50\xE2\x20\0\0\n\0\0\x59\xE3\x16\0\0\n\x98\x10\x9F\xE5\x4\0\xA0\xE1\xBC\xFE\xFF\xEB\0\x60\x50\xE2\xF1\xFF\xFF\n\x4\0\xA0\xE1\x85\xFF\xFF\xEB\xC8\x30\x40\xE2\x63\0\x53\xE3\0\x90\xA0\xE1\a\0\0\x9A\x64\x10\xA0\xE3\x1B\0\0\xEB\n\x10\xA0\xE3\x91\0\x4\xE0\t\0\xA0\xE1\x9F\0\0\xEB\x1\0\x84">>$F +printf "\xE0\xE\0\0\xEA\x4\x10\x86\xE2\x4\x30\x61\xE0\x3\x50\x85\xE0\0\0\0\xEA\x4\x10\xA0\xE1\x5\x20\xA0\xE1\b\0\xA0\xE1\x9B\xFE\xFF\xEB\0\x90\xA0\xE3\xD9\xFF\xFF\xEA\x3\0\xA0\xE3\x2\0\0\xEA\x5\0\xA0\xE1\0\0\0\xEA\x5\0\xA0\xE3\x1\xDA\x8D\xE2\x28\xD0\x8D\xE2\xF0\x87\xBD\xE8\xE9\xE\x1\0\xEC\xE\x1\0\xF2\xE\x1\0\x4\xF\x1\0\0\0\x51\xE3\x81\0\0\n\x1\xC0\x20\xE0\0\x10\x61\x42\x1\x20\x51\xE2\x70\0\0\n\0\x30\xB0\xE1\0\x30\x60\x42\x1\0\x53\xE1\x6F\0\0\x9A\x2\0\x11\xE1\x71\0\0\n\x13\x2F\x6F\xE1\x11\xF\x6F\xE1\x2\x20\x40\xE0\x1F\x20\x72\xE2\x82\x20\x82\x10\0\0\xA0\xE3\x2\xF1\x8F\x10\0\0\xA0\xE1\x81\xF\x53\xE1\0\0\xA0\xE0\x81\x3F\x43\x20\x1\xF\x53\xE1\0\0\xA0\xE0\x1\x3F\x43\x20\x81\xE\x53\xE1\0\0\xA0\xE0\x81">>$F +printf "\x3E\x43\x20\x1\xE\x53\xE1\0\0\xA0\xE0\x1\x3E\x43\x20\x81\r\x53\xE1\0\0\xA0\xE0\x81\x3D\x43\x20\x1\r\x53\xE1\0\0\xA0\xE0\x1\x3D\x43\x20\x81\f\x53\xE1\0\0\xA0\xE0\x81\x3C\x43\x20\x1\f\x53\xE1\0\0\xA0\xE0\x1\x3C\x43\x20\x81\v\x53\xE1\0\0\xA0\xE0\x81\x3B\x43\x20\x1\v\x53\xE1\0\0\xA0\xE0\x1\x3B\x43\x20\x81\n\x53\xE1\0\0\xA0\xE0\x81\x3A\x43\x20\x1\n\x53\xE1\0\0\xA0\xE0\x1\x3A\x43\x20\x81\t\x53\xE1\0\0\xA0\xE0\x81\x39\x43\x20\x1\t\x53\xE1\0\0\xA0\xE0\x1\x39\x43\x20\x81\b\x53\xE1\0\0\xA0\xE0\x81\x38\x43\x20\x1\b\x53\xE1\0\0\xA0\xE0\x1\x38\x43\x20\x81\a\x53\xE1\0\0\xA0\xE0\x81\x37\x43\x20\x1\a\x53\xE1\0\0\xA0\xE0\x1\x37\x43\x20\x81\x6\x53\xE1\0\0\xA0\xE0\x81\x36\x43\x20\x1\x6\x53\xE1\0\0\xA0\xE0">>$F +printf "\x1\x36\x43\x20\x81\x5\x53\xE1\0\0\xA0\xE0\x81\x35\x43\x20\x1\x5\x53\xE1\0\0\xA0\xE0\x1\x35\x43\x20\x81\x4\x53\xE1\0\0\xA0\xE0\x81\x34\x43\x20\x1\x4\x53\xE1\0\0\xA0\xE0\x1\x34\x43\x20\x81\x3\x53\xE1\0\0\xA0\xE0\x81\x33\x43\x20\x1\x3\x53\xE1\0\0\xA0\xE0\x1\x33\x43\x20\x81\x2\x53\xE1\0\0\xA0\xE0\x81\x32\x43\x20\x1\x2\x53\xE1\0\0\xA0\xE0\x1\x32\x43\x20\x81\x1\x53\xE1\0\0\xA0\xE0\x81\x31\x43\x20\x1\x1\x53\xE1\0\0\xA0\xE0\x1\x31\x43\x20\x81\0\x53\xE1\0\0\xA0\xE0\x81\x30\x43\x20\x1\0\x53\xE1\0\0\xA0\xE0\x1\x30\x43\x20\0\0\x5C\xE3\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x3C\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\xA0\x33\xCC\xF\xA0\x1\x1\0\x80\x3\x1E\xFF\x2F\xE1\x11\x2F\x6F\xE1\x1F\x20\x62\xE2\0\0\x5C\xE3">>$F +printf "\x33\x2\xA0\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x50\xE3\x2\x1\xE0\xC3\x2\x1\xA0\xB3\a\0\0\xEA\0\0\x51\xE3\xF9\xFF\xFF\n\x3\x40\x2D\xE9\x75\xFF\xFF\xEB\x6\x40\xBD\xE8\x92\0\x3\xE0\x3\x10\x41\xE0\x1E\xFF\x2F\xE1\x2\x40\x2D\xE9\b\0\xA0\xE3\x2D\xFE\xFF\xEB\x2\x80\xBD\xE8\r\xC0\xA0\xE1\xF0\xDF\x2D\xE9\x4\xB0\x4C\xE2\xF0\xAF\x1B\xE9\x25\x73\x20\x25\x64\x20\x25\x73\0\x38\x30\0\x47\x45\x54\x20\x2F\0\x20\x48\x54\x54\x50\x2F\x31\x2E\x30\r\n\x48\x6F\x73\x74\x3A\x20\0\r\n\r\n\0\x72\x75\x6E\0\x2F\x74\x6D\x70\x2F\x75\x67\x65\x74\x58\x58\x58\x58\x58\x58\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xB4\t\x1\0\x70\t\x1\0\0\0\0\0\x1\0\0\0\x1\0\0\0\f\0\0\0\x28\x6\x1\0\r\0\0\0\xD0\xE\x1\0\x19\0\0\0\0\x10\x2\0\x1B\0\0\0\x4\0\0\0\x1A\0\0\0\x4\x10\x2\0\x1C\0\0\0\x4\0\0\0\x4\0\0\0\b\x1\x1\0\x5\0\0\0\x24\x4\x1\0\x6\0\0\0\xE4\x1\x1\0\n\0\0\0\x2B\x1\0\0\v\0\0\0\x10\0\0\0\x15\0\0\0\0\0\0\0\x3\0\0\0\xC4\x10\x2\0\x2\0\0\0\xD8\0\0\0\x14\0\0\0\x11\0\0\0\x17\0\0\0\x50\x5\x1\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\f\x10\x2\0\0\0\0\0\0\0\0\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\0\0\0\0\0\0\0\0\x41\x2D\0\0\0\x61\x65\x61\x62\x69\0\x1\x23\0\0\0\x5\x41\x52\x4D\x39\x32\x36\x45\x4A\x2D\x53\0\x6\x5\b\x1\t\x1\x12\x4\x14\x1\x15\x1\x17\x3\x18\x1\x1A\x2\0\x2E\x73\x68\x73\x74\x72\x74\x61\x62\0\x2E\x69\x6E\x74\x65\x72\x70\0\x2E\x68\x61\x73\x68\0\x2E\x64">>$F +printf "\x79\x6E\x73\x79\x6D\0\x2E\x64\x79\x6E\x73\x74\x72\0\x2E\x72\x65\x6C\x2E\x70\x6C\x74\0\x2E\x69\x6E\x69\x74\0\x2E\x74\x65\x78\x74\0\x2E\x66\x69\x6E\x69\0\x2E\x72\x6F\x64\x61\x74\x61\0\x2E\x65\x68\x5F\x66\x72\x61\x6D\x65\0\x2E\x69\x6E\x69\x74\x5F\x61\x72\x72\x61\x79\0\x2E\x66\x69\x6E\x69\x5F\x61\x72\x72\x61\x79\0\x2E\x6A\x63\x72\0\x2E\x64\x79\x6E\x61\x6D\x69\x63\0\x2E\x67\x6F\x74\0\x2E\x64\x61\x74\x61\0\x2E\x62\x73\x73\0\x2E\x41\x52\x4D\x2E\x61\x74\x74\x72\x69\x62\x75\x74\x65\x73\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\v\0\0\0\x1\0\0\0\x2\0\0\0\xF4\0\x1\0\xF4\0\0\0\x14\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x13\0\0\0\x5\0\0\0\x2\0\0\0\b\x1\x1">>$F +printf "\0\b\x1\0\0\xDC\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x19\0\0\0\v\0\0\0\x2\0\0\0\xE4\x1\x1\0\xE4\x1\0\0\x40\x2\0\0\x4\0\0\0\x1\0\0\0\x4\0\0\0\x10\0\0\0\x21\0\0\0\x3\0\0\0\x2\0\0\0\x24\x4\x1\0\x24\x4\0\0\x2B\x1\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x29\0\0\0\t\0\0\0\x42\0\0\0\x50\x5\x1\0\x50\x5\0\0\xD8\0\0\0\x3\0\0\0\a\0\0\0\x4\0\0\0\b\0\0\0\x32\0\0\0\x1\0\0\0\x6\0\0\0\x28\x6\x1\0\x28\x6\0\0\x10\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x2D\0\0\0\x1\0\0\0\x6\0\0\0\x38\x6\x1\0\x38\x6\0\0\x58\x1\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x38\0\0\0\x1\0\0\0\x6\0\0\0\x90\a\x1\0\x90\a\0\0\x40\a\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x3E\0\0\0\x1\0\0\0\x6\0\0\0\xD0\xE\x1\0\xD0\xE\0\0\x10\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\x4\0\0\0\0\0\0\0\x44\0\0\0\x1\0\0\0\x32\0\0\0\xE0\xE\x1\0\xE0\xE\0\0\x3D\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\x1\0\0\0\x4C\0\0\0\x1\0\0\0\x2\0\0\0\x20\xF\x1\0\x20\xF\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x56\0\0\0\xE\0\0\0\x3\0\0\0\0\x10\x2\0\0\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x62\0\0\0\xF\0\0\0\x3\0\0\0\x4\x10\x2\0\x4\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x6E\0\0\0\x1\0\0\0\x3\0\0\0\b\x10\x2\0\b\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x73\0\0\0\x6\0\0\0\x3\0\0\0\f\x10\x2\0\f\x10\0\0\xB8\0\0\0\x4\0\0\0\0\0\0\0\x4\0\0\0\b\0\0\0\x7C\0\0\0\x1\0\0\0\x3\0\0\0\xC4\x10\x2\0\xC4\x10\0\0\x78\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x81\0\0\0\x1">>$F +printf "\0\0\0\x3\0\0\0\x3C\x11\x2\0\x3C\x11\0\0\b\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x87\0\0\0\b\0\0\0\x3\0\0\0\x44\x11\x2\0\x44\x11\0\0\x1C\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x8C\0\0\0\x3\0\0\x70\0\0\0\0\0\0\0\0\x44\x11\0\0\x2E\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x1\0\0\0\x3\0\0\0\0\0\0\0\0\0\0\0\x72\x11\0\0\x9C\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0">>$F + diff --git a/frontend/apps/site/public/uget/uget.arm-hisiv510-linux.sh b/frontend/apps/site/public/uget/uget.arm-hisiv510-linux.sh new file mode 100644 index 00000000..266f37e5 --- /dev/null +++ b/frontend/apps/site/public/uget/uget.arm-hisiv510-linux.sh @@ -0,0 +1,26 @@ +cd /tmp;F=uget;true>$F;chmod +x $F +printf "\x7F\x45\x4C\x46\x1\x1\x1\0\0\0\0\0\0\0\0\0\x2\0\x28\0\x1\0\0\0\xB8\b\x1\0\x34\0\0\0\x10\x12\0\0\0\x2\0\x5\x34\0\x20\0\x6\0\x28\0\x15\0\x14\0\x6\0\0\0\x34\0\0\0\x34\0\x1\0\x34\0\x1\0\xC0\0\0\0\xC0\0\0\0\x5\0\0\0\x4\0\0\0\x3\0\0\0\xF4\0\0\0\xF4\0\x1\0\xF4\0\x1\0\x14\0\0\0\x14\0\0\0\x4\0\0\0\x1\0\0\0\x1\0\0\0\0\0\0\0\0\0\x1\0\0\0\x1\0\x1C\xF\0\0\x1C\xF\0\0\x5\0\0\0\0\0\x1\0\x1\0\0\0\0\x10\0\0\0\x10\x2\0\0\x10\x2\0\x44\x1\0\0\x60\x1\0\0\x6\0\0\0\0\0\x1\0\x2\0\0\0\f\x10\0\0\f\x10\x2\0\f\x10\x2\0\xB8\0\0\0\xB8\0\0\0\x6\0\0\0\x4\0\0\0\x51\xE5\x74\x64\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x6\0\0\0\x10\0\0\0\x2F\x6C\x69\x62\x2F\x6C\x64\x2D\x75\x43\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x30\0\x11\0">$F +printf "\0\0\x24\0\0\0\x11\0\0\0\x1E\0\0\0\x15\0\0\0"\0\0\0\x20\0\0\0\xE\0\0\0\x23\0\0\0\x14\0\0\0\x6\0\0\0\x21\0\0\0\f\0\0\0\x1C\0\0\0\x1\0\0\0\x1F\0\0\0\x1A\0\0\0\x17\0\0\0\x1D\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x5\0\0\0\a\0\0\0\0\0\0\0\t\0\0\0\v\0\0\0\b\0\0\0\0\0\0\0\x2\0\0\0\n\0\0\0\0\0\0\0\0\0\0\0\r\0\0\0\0\0\0\0\x13\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xF\0\0\0\x18\0\0\0\0\0\0\0\0\0\0\0\x19\0\0\0\x16\0\0\0\0\0\0\0\x10\0\0\0\x1B\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xA8\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x53\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x65\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x3E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xE5\0\0\0\x60\x11\x2\0\0\0\0\0\x10">>$F +printf "\0\x12\0\x7E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xD6\0\0\0\x44\x11\x2\0\0\0\0\0\x10\0\x12\0\xA1\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x6D\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x58\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xBD\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x38\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xFD\0\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\xE4\0\0\0\x60\x11\x2\0\0\0\0\0\x10\0\x12\0\x79\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xCF\0\0\0\xB8\b\x1\0\x50\0\0\0\x12\0\b\0\xB6\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x93\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x26\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x72\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x4B\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xAF\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x85\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xCA\0\0\0\x44\x11">>$F +printf "\x2\0\0\0\0\0\x10\0\x12\0\x31\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xF0\0\0\0\x60\x11\x2\0\0\0\0\0\x10\0\x12\0\x9A\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x1A\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xC3\0\0\0\x44\x11\x2\0\0\0\0\0\x10\0\x11\0\xF8\0\0\0\x60\x11\x2\0\0\0\0\0\x10\0\x12\0\v\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x15\x1\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\x2B\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x45\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\0\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x30\0\x73\x74\x72\x6C\x65\x6E\0\x6D\x6B\x73\x74\x65\x6D\x70\0\x67\x65\x74\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x66\x6F\x72\x6B\0\x63\x6C\x6F\x73\x65\0\x6D\x65\x6D\x73\x65\x74\0\x77\x72\x69\x74\x65\0\x66\x63\x68\x6D\x6F\x64">>$F +printf "\0\x72\x61\x69\x73\x65\0\x73\x74\x72\x6E\x63\x61\x74\0\x72\x65\x63\x76\0\x66\x72\x65\x65\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x63\x6F\x6E\x6E\x65\x63\x74\0\x73\x65\x6E\x64\0\x73\x73\x63\x61\x6E\x66\0\x77\x61\x69\x74\0\x6D\x65\x6D\x63\x70\x79\0\x5F\x5F\x75\x43\x6C\x69\x62\x63\x5F\x6D\x61\x69\x6E\0\x75\x6E\x6C\x69\x6E\x6B\0\x73\x74\x72\x63\x6D\x70\0\x73\x6F\x63\x6B\x65\x74\0\x73\x74\x72\x63\x70\x79\0\x65\x78\x65\x63\x6C\x70\0\x73\x74\x72\x73\x74\x72\0\x61\x62\x6F\x72\x74\0\x5F\x65\x64\x61\x74\x61\0\x5F\x5F\x62\x73\x73\x5F\x73\x74\x61\x72\x74\0\x5F\x5F\x62\x73\x73\x5F\x73\x74\x61\x72\x74\x5F\x5F\0\x5F\x5F\x62\x73\x73\x5F\x65\x6E\x64\x5F\x5F\0\x5F\x5F\x65\x6E\x64\x5F\x5F\0\x5F\x65\x6E\x64\0\x5F">>$F +printf "\x5F\x64\x65\x72\x65\x67\x69\x73\x74\x65\x72\x5F\x66\x72\x61\x6D\x65\x5F\x69\x6E\x66\x6F\0\x5F\x5F\x72\x65\x67\x69\x73\x74\x65\x72\x5F\x66\x72\x61\x6D\x65\x5F\x69\x6E\x66\x6F\0\0\xD0\x10\x2\0\x16\x1\0\0\xD4\x10\x2\0\x16\x2\0\0\xD8\x10\x2\0\x16\x3\0\0\xDC\x10\x2\0\x16\x4\0\0\xE0\x10\x2\0\x16\x6\0\0\xE4\x10\x2\0\x16\b\0\0\xE8\x10\x2\0\x16\t\0\0\xEC\x10\x2\0\x16\n\0\0\xF0\x10\x2\0\x16\v\0\0\xF4\x10\x2\0\x16\f\0\0\xF8\x10\x2\0\x16\r\0\0\xFC\x10\x2\0\x16\xF\0\0\0\x11\x2\0\x16\x11\0\0\x4\x11\x2\0\x16\x12\0\0\b\x11\x2\0\x16\x13\0\0\f\x11\x2\0\x16\x14\0\0\x10\x11\x2\0\x16\x15\0\0\x14\x11\x2\0\x16\x16\0\0\x18\x11\x2\0\x16\x17\0\0\x1C\x11\x2\0\x16\x19\0\0\x20\x11\x2\0\x16\x1B\0\0\x24\x11\x2\0\x16">>$F +printf "\x1C\0\0\x28\x11\x2\0\x16\x1D\0\0\x2C\x11\x2\0\x16\x20\0\0\x30\x11\x2\0\x16\x21\0\0\x34\x11\x2\0\x16"\0\0\x38\x11\x2\0\x16\x23\0\0\r\xC0\xA0\xE1\xF0\xDF\x2D\xE9\x4\xB0\x4C\xE2\xF0\xAF\x1B\xE9\x4\xE0\x2D\xE5\x4\xE0\x9F\xE5\xE\xE0\x8F\xE0\b\xF0\xBE\xE5\x7C\n\x1\0\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x7C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x74\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x6C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x64\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x5C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x54\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x4C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x44\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x3C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10">>$F +printf "\xCA\x8C\xE2\x34\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x2C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x24\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x1C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x14\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\f\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x4\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xFC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xF4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xEC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xE4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xDC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xD4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xCC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xC4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C">>$F +printf "\xE2\xBC\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xB4\xF9\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xAC\xF9\xBC\xE5\xF0\x40\x2D\xE9\x1\0\x50\xE3\x1C\xD0\x4D\xE2\v\0\0\xDA\x3\0\x50\xE3\x1\x50\xA0\xE1\0\x40\xA0\x13\x1\x30\xA0\x13\b\0\0\x1A\x4\x10\x91\xE5\xF0\0\x9F\xE5\xDE\xFF\xFF\xEB\0\0\x50\xE3\x1\x40\xA0\x3\x2\x30\xA0\x3\x1\0\0\n\x6\x40\xA0\xE3\x2E\0\0\xEA\x3\x71\x95\xE7\a\x30\xA0\xE1\x3\x10\xA0\xE1\x1\x20\xD3\xE4\0\0\x52\xE3\x2\x50\xA0\x1\x4\0\0\n\x2F\0\x52\xE3\xF8\xFF\xFF\x1A\0\x30\xA0\xE3\x1\x50\x81\xE2\0\x30\xC1\xE5\x10\x20\xA0\xE3\xA0\x10\x9F\xE5\b\0\x8D\xE2\x98\xFF\xFF\xEB\0\0\x54\xE3\x1\x60\xA0\x3\x2\0\0\n\b\0\x8D\xE2\xC6\xFF\xFF\xEB\0\x60\xA0\xE1\x5\x20\xA0\xE1\a\x10\xA0\xE1\x6\0">>$F +printf "\xA0\xE1\x7F\0\0\xEB\0\x50\x50\xE2\x15\0\0\x1A\0\0\x54\xE3\x10\0\0\n\x5\x1D\xA0\xE3\x6\0\xA0\xE1\x84\xFF\xFF\xEB\x6\0\xA0\xE1\xC4\xFF\xFF\xEB\xA2\xFF\xFF\xEB\0\x20\x50\xE2\x5\0\0\n\x4\0\x8D\xE2\x95\xFF\xFF\xEB\x5\x50\xDD\xE5\b\0\x8D\xE2\x98\xFF\xFF\xEB\a\0\0\xEA\b\x10\x8D\xE2\x1\0\xA0\xE1\xA0\xFF\xFF\xEB\x4\0\xA0\xE1\x1C\xD0\x8D\xE2\xF0\x80\xBD\xE8\0\0\x54\xE3\xF4\xFF\xFF\x1A\x5\x40\xA0\xE1\xF8\xFF\xFF\xEA\x1\xF\x1\0\x5\xF\x1\0\0\xB0\xA0\xE3\0\xE0\xA0\xE3\x4\x10\x9D\xE4\r\x20\xA0\xE1\x4\x20\x2D\xE5\x4\0\x2D\xE5\x10\xC0\x9F\xE5\x4\xC0\x2D\xE5\f\0\x9F\xE5\f\x30\x9F\xE5\x8F\xFF\xFF\xEA\x70\xFF\xFF\xEB\xC8\xE\x1\0\x90\a\x1\0\x28\x6\x1\0\x1C\x30\x9F\xE5\x1C\0\x9F\xE5\0\x30\x43\xE0\x6\0\x53">>$F +printf "\xE3\x1E\xFF\x2F\x91\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x47\x11\x2\0\x44\x11\x2\0\0\0\0\0\x24\x10\x9F\xE5\x24\0\x9F\xE5\0\x10\x41\xE0\x41\x11\xA0\xE1\xA1\x1F\x81\xE0\xC1\x10\xB0\xE1\x1E\xFF\x2F\x1\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x44\x11\x2\0\x44\x11\x2\0\0\0\0\0\x10\x40\x2D\xE9\x2C\x40\x9F\xE5\0\x30\xD4\xE5\0\0\x53\xE3\x10\x80\xBD\x18\xDF\xFF\xFF\xEB\x1C\x30\x9F\xE5\0\0\x53\xE3\x1\0\0\n\x14\0\x9F\xE5\x4E\xFF\xFF\xEB\x1\x30\xA0\xE3\0\x30\xC4\xE5\x10\x80\xBD\xE8\x44\x11\x2\0\0\0\0\0\x18\xF\x1\0\x40\x30\x9F\xE5\x10\x40\x2D\xE9\0\0\x53\xE3\x2\0\0\n\x34\x10\x9F\xE5\x34\0\x9F\xE5\x6B\xFF\xFF\xEB\x30\0\x9F\xE5\0\x30\x90\xE5\0\0\x53\xE3\x1\0\0">>$F +printf "\x1A\x10\x40\xBD\xE8\xD3\xFF\xFF\xEA\x1C\x30\x9F\xE5\0\0\x53\xE3\xFA\xFF\xFF\n\x33\xFF\x2F\xE1\xF8\xFF\xFF\xEA\0\0\0\0\x48\x11\x2\0\x18\xF\x1\0\b\x10\x2\0\0\0\0\0\x4\xE0\x2D\xE5\x2\xDA\x4D\xE2\x14\xD0\x4D\xE2\x1\x3A\x8D\xE2\x10\x30\x83\xE2\0\x30\x8D\xE5\x10\x20\x8D\xE2\f\x30\x8D\xE2\x18\x10\x9F\xE5\x36\xFF\xFF\xEB\x1\0\x50\xE3\0\0\xE0\xD3\f\0\x9D\xC5\x2\xDA\x8D\xE2\x14\xD0\x8D\xE2\x4\xF0\x9D\xE4\xD8\xE\x1\0\xF0\x4F\x2D\xE9\x1\x60\xA0\xE1\x1\x90\xA0\xE3\x1\xDA\x4D\xE2\x34\xD0\x4D\xE2\x30\x40\x8D\xE2\0\x10\xA0\xE3\0\xA0\xA0\xE1\x2\x70\xA0\xE1\x10\0\x8D\xE2\x20\x20\xA0\xE3\x2F\xFF\xFF\xEB\x24\x30\x44\xE2\x10\x20\x8D\xE2\xE4\x11\x9F\xE5\x6\0\xA0\xE1\x18\x90\x4\xE5\x32\xFF\xFF\xEB\0\x30\x50">>$F +printf "\xE2\0\x50\xE0\x3\0\x30\x8D\xE5\x24\xB0\x14\x5\x5\x80\xA0\x1\xF\0\0\n\x3\x90\xA0\xE3\x5E\0\0\xEA\a\0\x9B\xE9\xF5\xFE\xFF\xEB\0\x50\x50\xE2\x5E\0\0\xBA\x10\x20\x9B\xE5\x14\x10\x9B\xE5\xE7\xFE\xFF\xEB\0\0\x50\xE3\x6\0\0\xAA\x5\0\xA0\xE1\x28\xFF\xFF\xEB\b\x50\xA0\xE1\x4\x90\xA0\xE3\x1C\xB0\x9B\xE5\0\0\x5B\xE3\xEF\xFF\xFF\x1A\x24\0\x14\xE5\xEB\xFE\xFF\xEB\0\0\x55\xE3\x4A\0\0\xBA\x68\x11\x9F\xE5\x4\0\xA0\xE1\xD1\xFE\xFF\xEB\0\0\x57\xE3\x6\0\0\n\x4\0\xA0\xE1\x12\xFF\xFF\xEB\xFF\x2E\x60\xE2\xF\x20\x82\xE2\a\x10\xA0\xE1\x4\0\xA0\xE1\xF8\xFE\xFF\xEB\x4\0\xA0\xE1\v\xFF\xFF\xEB\x34\x71\x9F\xE5\x34\x11\x9F\xE5\0\x20\x47\xE0\x4\0\xA0\xE1\xF1\xFE\xFF\xEB\x4\0\xA0\xE1\x4\xFF\xFF\xEB\x6\x10\xA0\xE1\0">>$F +printf "\x20\x47\xE0\x4\0\xA0\xE1\xEB\xFE\xFF\xEB\x4\0\xA0\xE1\xFE\xFE\xFF\xEB\b\x11\x9F\xE5\0\x20\x47\xE0\x4\0\xA0\xE1\xE5\xFE\xFF\xEB\x4\0\xA0\xE1\xF8\xFE\xFF\xEB\0\x30\xA0\xE3\0\x60\xA0\xE1\0\x20\xA0\xE1\x4\x10\xA0\xE1\x5\0\xA0\xE1\xBF\xFE\xFF\xEB\0\0\x56\xE1\x5\x90\xA0\x13\x20\0\0\x1A\x1\x60\xA0\xE3\0\x80\xA0\xE3\x1\x7A\xA0\xE3\xC0\xB0\x9F\xE5\b\x30\xA0\xE1\a\x20\xA0\xE1\x4\x10\xA0\xE1\x5\0\xA0\xE1\xA4\xFE\xFF\xEB\0\x90\x50\xE2\x15\0\0\n\0\0\x56\xE3\x21\0\0\n\v\x10\xA0\xE1\x4\0\xA0\xE1\xBE\xFE\xFF\xEB\0\x10\x50\xE2\x4\x10\x8D\xE5\xF0\xFF\xFF\n\x4\0\xA0\xE1\x81\xFF\xFF\xEB\xC8\x20\x40\xE2\x63\0\x52\xE3\0\x60\xA0\xE1\x4\x10\x9D\xE5\f\0\0\x9A\x64\x10\xA0\xE3\x19\0\0\xEB\n\x10\xA0\xE3\x91\0\t">>$F +printf "\xE0\x6\0\xA0\xE1\x9D\0\0\xEB\x1\x90\x89\xE0\t\0\xA0\xE1\x1\xDA\x8D\xE2\x34\xD0\x8D\xE2\xF0\x8F\xBD\xE8\x2\x90\xA0\xE3\xA7\xFF\xFF\xEA\x4\x10\x81\xE2\x4\x20\x41\xE0\x2\x90\x49\xE0\t\x20\xA0\xE1\n\0\xA0\xE1\x98\xFE\xFF\xEB\0\x60\x9D\xE5\xD4\xFF\xFF\xEA\x4\x10\xA0\xE1\xF8\xFF\xFF\xEA\xE1\xE\x1\0\xE4\xE\x1\0\xFF\xF\0\0\xEA\xE\x1\0\xFC\xE\x1\0\0\0\x51\xE3\x81\0\0\n\x1\xC0\x20\xE0\0\x10\x61\x42\x1\x20\x51\xE2\x70\0\0\n\0\x30\xB0\xE1\0\x30\x60\x42\x1\0\x53\xE1\x6F\0\0\x9A\x2\0\x11\xE1\x71\0\0\n\x13\x2F\x6F\xE1\x11\xF\x6F\xE1\x2\x20\x40\xE0\x1F\x20\x72\xE2\x82\x20\x82\x10\0\0\xA0\xE3\x2\xF1\x8F\x10\0\0\xA0\xE1\x81\xF\x53\xE1\0\0\xA0\xE0\x81\x3F\x43\x20\x1\xF\x53\xE1\0\0\xA0\xE0\x1\x3F\x43\x20">>$F +printf "\x81\xE\x53\xE1\0\0\xA0\xE0\x81\x3E\x43\x20\x1\xE\x53\xE1\0\0\xA0\xE0\x1\x3E\x43\x20\x81\r\x53\xE1\0\0\xA0\xE0\x81\x3D\x43\x20\x1\r\x53\xE1\0\0\xA0\xE0\x1\x3D\x43\x20\x81\f\x53\xE1\0\0\xA0\xE0\x81\x3C\x43\x20\x1\f\x53\xE1\0\0\xA0\xE0\x1\x3C\x43\x20\x81\v\x53\xE1\0\0\xA0\xE0\x81\x3B\x43\x20\x1\v\x53\xE1\0\0\xA0\xE0\x1\x3B\x43\x20\x81\n\x53\xE1\0\0\xA0\xE0\x81\x3A\x43\x20\x1\n\x53\xE1\0\0\xA0\xE0\x1\x3A\x43\x20\x81\t\x53\xE1\0\0\xA0\xE0\x81\x39\x43\x20\x1\t\x53\xE1\0\0\xA0\xE0\x1\x39\x43\x20\x81\b\x53\xE1\0\0\xA0\xE0\x81\x38\x43\x20\x1\b\x53\xE1\0\0\xA0\xE0\x1\x38\x43\x20\x81\a\x53\xE1\0\0\xA0\xE0\x81\x37\x43\x20\x1\a\x53\xE1\0\0\xA0\xE0\x1\x37\x43\x20\x81\x6\x53\xE1\0\0\xA0\xE0\x81\x36">>$F +printf "\x43\x20\x1\x6\x53\xE1\0\0\xA0\xE0\x1\x36\x43\x20\x81\x5\x53\xE1\0\0\xA0\xE0\x81\x35\x43\x20\x1\x5\x53\xE1\0\0\xA0\xE0\x1\x35\x43\x20\x81\x4\x53\xE1\0\0\xA0\xE0\x81\x34\x43\x20\x1\x4\x53\xE1\0\0\xA0\xE0\x1\x34\x43\x20\x81\x3\x53\xE1\0\0\xA0\xE0\x81\x33\x43\x20\x1\x3\x53\xE1\0\0\xA0\xE0\x1\x33\x43\x20\x81\x2\x53\xE1\0\0\xA0\xE0\x81\x32\x43\x20\x1\x2\x53\xE1\0\0\xA0\xE0\x1\x32\x43\x20\x81\x1\x53\xE1\0\0\xA0\xE0\x81\x31\x43\x20\x1\x1\x53\xE1\0\0\xA0\xE0\x1\x31\x43\x20\x81\0\x53\xE1\0\0\xA0\xE0\x81\x30\x43\x20\x1\0\x53\xE1\0\0\xA0\xE0\x1\x30\x43\x20\0\0\x5C\xE3\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x3C\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\xA0\x33\xCC\xF\xA0\x1\x1\0\x80\x3\x1E\xFF\x2F\xE1\x11\x2F">>$F +printf "\x6F\xE1\x1F\x20\x62\xE2\0\0\x5C\xE3\x33\x2\xA0\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x50\xE3\x2\x1\xE0\xC3\x2\x1\xA0\xB3\a\0\0\xEA\0\0\x51\xE3\xF9\xFF\xFF\n\x3\x40\x2D\xE9\x75\xFF\xFF\xEB\x6\x40\xBD\xE8\x92\0\x3\xE0\x3\x10\x41\xE0\x1E\xFF\x2F\xE1\x2\x40\x2D\xE9\b\0\xA0\xE3\x2F\xFE\xFF\xEB\x2\x80\xBD\xE8\r\xC0\xA0\xE1\xF0\xDF\x2D\xE9\x4\xB0\x4C\xE2\xF0\xAF\x1B\xE9\x25\x73\x20\x25\x64\x20\x25\x73\0\x38\x30\0\x47\x45\x54\x20\x2F\0\x20\x48\x54\x54\x50\x2F\x31\x2E\x30\r\n\x48\x6F\x73\x74\x3A\x20\0\r\n\r\n\0\x72\x75\x6E\0\x2F\x74\x6D\x70\x2F\x75\x67\x65\x74\x58\x58\x58\x58\x58\x58\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\xA0\t\x1\0\x5C\t\x1\0\0\0\0\0\x1\0\0\0\x1\0\0\0\f\0\0\0\x28\x6\x1\0\r\0\0\0\xC8\xE\x1\0\x19\0\0\0\0\x10\x2\0\x1B\0\0\0\x4\0\0\0\x1A\0\0\0\x4\x10\x2\0\x1C\0\0\0\x4\0\0\0\x4\0\0\0\b\x1\x1\0\x5\0\0\0\x24\x4\x1\0\x6\0\0\0\xE4\x1\x1\0\n\0\0\0\x2B\x1\0\0\v\0\0\0\x10\0\0\0\x15\0\0\0\0\0\0\0\x3\0\0\0\xC4\x10\x2\0\x2\0\0\0\xD8\0\0\0\x14\0\0\0">>$F +printf "\x11\0\0\0\x17\0\0\0\x50\x5\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\f\x10\x2\0\0\0\0\0\0\0\0\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\x38\x6\x1\0\0\0\0\0\0\0\0\0\x41\x2D\0\0\0\x61\x65\x61\x62\x69\0\x1\x23\0\0\0\x5\x41\x52\x4D\x39\x32\x36\x45\x4A\x2D\x53\0\x6\x5\b\x1\t\x1\x12\x4\x14\x1\x15\x1\x17\x3\x18\x1\x1A\x2\0\x2E\x73\x68\x73\x74\x72\x74\x61\x62\0\x2E\x69">>$F +printf "\x6E\x74\x65\x72\x70\0\x2E\x68\x61\x73\x68\0\x2E\x64\x79\x6E\x73\x79\x6D\0\x2E\x64\x79\x6E\x73\x74\x72\0\x2E\x72\x65\x6C\x2E\x70\x6C\x74\0\x2E\x69\x6E\x69\x74\0\x2E\x74\x65\x78\x74\0\x2E\x66\x69\x6E\x69\0\x2E\x72\x6F\x64\x61\x74\x61\0\x2E\x65\x68\x5F\x66\x72\x61\x6D\x65\0\x2E\x69\x6E\x69\x74\x5F\x61\x72\x72\x61\x79\0\x2E\x66\x69\x6E\x69\x5F\x61\x72\x72\x61\x79\0\x2E\x6A\x63\x72\0\x2E\x64\x79\x6E\x61\x6D\x69\x63\0\x2E\x67\x6F\x74\0\x2E\x64\x61\x74\x61\0\x2E\x62\x73\x73\0\x2E\x41\x52\x4D\x2E\x61\x74\x74\x72\x69\x62\x75\x74\x65\x73\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\v\0\0\0\x1\0\0\0\x2\0\0\0\xF4\0\x1\0\xF4\0\0\0\x14\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\x1\0\0\0\0\0\0\0\x13\0\0\0\x5\0\0\0\x2\0\0\0\b\x1\x1\0\b\x1\0\0\xDC\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x19\0\0\0\v\0\0\0\x2\0\0\0\xE4\x1\x1\0\xE4\x1\0\0\x40\x2\0\0\x4\0\0\0\x1\0\0\0\x4\0\0\0\x10\0\0\0\x21\0\0\0\x3\0\0\0\x2\0\0\0\x24\x4\x1\0\x24\x4\0\0\x2B\x1\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x29\0\0\0\t\0\0\0\x42\0\0\0\x50\x5\x1\0\x50\x5\0\0\xD8\0\0\0\x3\0\0\0\x10\0\0\0\x4\0\0\0\b\0\0\0\x32\0\0\0\x1\0\0\0\x6\0\0\0\x28\x6\x1\0\x28\x6\0\0\x10\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x2D\0\0\0\x1\0\0\0\x6\0\0\0\x38\x6\x1\0\x38\x6\0\0\x58\x1\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x38\0\0\0\x1\0\0\0\x6\0\0\0\x90\a\x1\0\x90\a\0\0\x38\a\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x3E\0\0\0\x1">>$F +printf "\0\0\0\x6\0\0\0\xC8\xE\x1\0\xC8\xE\0\0\x10\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x44\0\0\0\x1\0\0\0\x32\0\0\0\xD8\xE\x1\0\xD8\xE\0\0\x3D\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\x1\0\0\0\x4C\0\0\0\x1\0\0\0\x2\0\0\0\x18\xF\x1\0\x18\xF\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x56\0\0\0\xE\0\0\0\x3\0\0\0\0\x10\x2\0\0\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x62\0\0\0\xF\0\0\0\x3\0\0\0\x4\x10\x2\0\x4\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x6E\0\0\0\x1\0\0\0\x3\0\0\0\b\x10\x2\0\b\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x73\0\0\0\x6\0\0\0\x3\0\0\0\f\x10\x2\0\f\x10\0\0\xB8\0\0\0\x4\0\0\0\0\0\0\0\x4\0\0\0\b\0\0\0\x7C\0\0\0\x1\0\0\0\x3\0\0\0\xC4\x10\x2\0\xC4\x10\0\0">>$F +printf "\x78\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x81\0\0\0\x1\0\0\0\x3\0\0\0\x3C\x11\x2\0\x3C\x11\0\0\b\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x87\0\0\0\b\0\0\0\x3\0\0\0\x44\x11\x2\0\x44\x11\0\0\x1C\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x8C\0\0\0\x3\0\0\x70\0\0\0\0\0\0\0\0\x44\x11\0\0\x2E\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x1\0\0\0\x3\0\0\0\0\0\0\0\0\0\0\0\x72\x11\0\0\x9C\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0">>$F + diff --git a/frontend/apps/site/public/uget/uget.arm-hisiv600-linux.sh b/frontend/apps/site/public/uget/uget.arm-hisiv600-linux.sh new file mode 100644 index 00000000..55637785 --- /dev/null +++ b/frontend/apps/site/public/uget/uget.arm-hisiv600-linux.sh @@ -0,0 +1,27 @@ +cd /tmp;F=uget;true>$F;chmod +x $F +printf "\x7F\x45\x4C\x46\x1\x1\x1\0\0\0\0\0\0\0\0\0\x2\0\x28\0\x1\0\0\0\x98\b\x1\0\x34\0\0\0\x74\x12\0\0\x2\x2\0\x5\x34\0\x20\0\b\0\x28\0\x19\0\x18\0\x1\0\0\x70\x30\xF\0\0\x30\xF\x1\0\x30\xF\x1\0\b\0\0\0\b\0\0\0\x4\0\0\0\x4\0\0\0\x6\0\0\0\x34\0\0\0\x34\0\x1\0\x34\0\x1\0\0\x1\0\0\0\x1\0\0\x5\0\0\0\x4\0\0\0\x3\0\0\0\x34\x1\0\0\x34\x1\x1\0\x34\x1\x1\0\x13\0\0\0\x13\0\0\0\x4\0\0\0\x1\0\0\0\x1\0\0\0\0\0\0\0\0\0\x1\0\0\0\x1\0\x3C\xF\0\0\x3C\xF\0\0\x5\0\0\0\0\0\x1\0\x1\0\0\0\0\x10\0\0\0\x10\x2\0\0\x10\x2\0\x74\x1\0\0\x78\x1\0\0\x6\0\0\0\0\0\x1\0\x2\0\0\0\f\x10\0\0\f\x10\x2\0\f\x10\x2\0\xE8\0\0\0\xE8\0\0\0\x6\0\0\0\x4\0\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x51\xE5\x74">$F +printf "\x64\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x6\0\0\0\x4\0\0\0\x2F\x6C\x69\x62\x2F\x6C\x64\x2D\x6C\x69\x6E\x75\x78\x2E\x73\x6F\x2E\x33\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x11\0\0\0\x1B\0\0\0\r\0\0\0\0\0\0\0\x14\0\0\0\x17\0\0\0\v\0\0\0\0\0\0\0\x16\0\0\0\t\0\0\0\x1A\0\0\0\0\0\0\0\x18\0\0\0\f\0\0\0\b\0\0\0\xE\0\0\0\0\0\0\0\n\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x2\0\0\0\0\0\0\0\0\0\0\0\x5\0\0\0\x3\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x6\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x10\0\0\0\x13\0\0\0\xF\0\0\0\a\0\0\0\x11\0\0\0\x15\0\0\0\x4\0\0\0\x19\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x7D\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x90\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\x12\0\0\0\xB1\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x89\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x57\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x42\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x20\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x12\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xB8\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xD0\0\0\0\0\0\0\0\0\0\0\0\x20\0\0\0\x5E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x4F\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x25\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x76\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xCA\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x3D\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\xA4\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x6A\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\v\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x96\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x49\0\0\0\0\0\0\0\0\0\0\0\x12\0\0">>$F +printf "\0\x84\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x9E\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x65\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x35\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\x19\0\0\0\0\0\0\0\0\0\0\0\x12\0\0\0\0\x6C\x69\x62\x63\x2E\x73\x6F\x2E\x36\0\x73\x6F\x63\x6B\x65\x74\0\x73\x74\x72\x63\x70\x79\0\x66\x63\x68\x6D\x6F\x64\0\x77\x61\x69\x74\0\x5F\x5F\x69\x73\x6F\x63\x39\x39\x5F\x73\x73\x63\x61\x6E\x66\0\x63\x6F\x6E\x6E\x65\x63\x74\0\x66\x6F\x72\x6B\0\x75\x6E\x6C\x69\x6E\x6B\0\x61\x62\x6F\x72\x74\0\x6D\x6B\x73\x74\x65\x6D\x70\0\x65\x78\x65\x63\x6C\x70\0\x73\x74\x72\x6C\x65\x6E\0\x73\x65\x6E\x64\0\x67\x65\x74\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x6D\x65\x6D\x73\x65\x74\0\x73\x74\x72\x73\x74\x72\0\x72\x65\x63\x76\0\x6D">>$F +printf "\x65\x6D\x63\x70\x79\0\x72\x61\x69\x73\x65\0\x73\x74\x72\x6E\x63\x61\x74\0\x63\x6C\x6F\x73\x65\0\x66\x72\x65\x65\x61\x64\x64\x72\x69\x6E\x66\x6F\0\x73\x74\x72\x63\x6D\x70\0\x5F\x5F\x6C\x69\x62\x63\x5F\x73\x74\x61\x72\x74\x5F\x6D\x61\x69\x6E\0\x77\x72\x69\x74\x65\0\x5F\x5F\x67\x6D\x6F\x6E\x5F\x73\x74\x61\x72\x74\x5F\x5F\0\x47\x4C\x49\x42\x43\x5F\x32\x2E\x37\0\x47\x4C\x49\x42\x43\x5F\x32\x2E\x34\0\0\0\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\0\0\x2\0\x2\0\x3\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\x2\0\0\0\x1\0\x2\0\x1\0\0\0\x10\0\0\0\0\0\0\0\x17\x69\x69\r\0\0\x3\0\xDF\0\0\0\x10\0\0\0\x14\x69\x69\r\0\0\x2\0\xE9\0\0\0\0\0\0\0\x68\x11\x2\0\x15\n\0\0\0\x11\x2\0\x16">>$F +printf "\x1\0\0\x4\x11\x2\0\x16\x2\0\0\b\x11\x2\0\x16\x3\0\0\f\x11\x2\0\x16\x4\0\0\x10\x11\x2\0\x16\x5\0\0\x14\x11\x2\0\x16\x6\0\0\x18\x11\x2\0\x16\a\0\0\x1C\x11\x2\0\x16\b\0\0\x20\x11\x2\0\x16\t\0\0\x24\x11\x2\0\x16\n\0\0\x28\x11\x2\0\x16\v\0\0\x2C\x11\x2\0\x16\f\0\0\x30\x11\x2\0\x16\r\0\0\x34\x11\x2\0\x16\xE\0\0\x38\x11\x2\0\x16\xF\0\0\x3C\x11\x2\0\x16\x10\0\0\x40\x11\x2\0\x16\x11\0\0\x44\x11\x2\0\x16\x12\0\0\x48\x11\x2\0\x16\x13\0\0\x4C\x11\x2\0\x16\x14\0\0\x50\x11\x2\0\x16\x15\0\0\x54\x11\x2\0\x16\x16\0\0\x58\x11\x2\0\x16\x17\0\0\x5C\x11\x2\0\x16\x18\0\0\x60\x11\x2\0\x16\x19\0\0\x64\x11\x2\0\x16\x1A\0\0\b\x40\x2D\xE9\xB1\0\0\xEB\b\x80\xBD\xE8\x4\xE0\x2D\xE5\x4\xE0\x9F\xE5\xE\xE0\x8F\xE0\b">>$F +printf "\xF0\xBE\xE5\xD4\n\x1\0\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xD4\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xCC\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xC4\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xBC\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xB4\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xAC\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\xA4\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x9C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x94\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x8C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x84\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x7C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x74\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x6C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C">>$F +printf "\xE2\x64\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x5C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x54\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x4C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x44\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x3C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x34\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x2C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x24\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x1C\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\x14\xFA\xBC\xE5\0\xC6\x8F\xE2\x10\xCA\x8C\xE2\f\xFA\xBC\xE5\x1\0\x50\xE3\x43\0\0\xDA\x3\0\x50\xE3\xF0\x40\x2D\xE9\x1\x30\xA0\x13\x1C\xD0\x4D\xE2\x1\x40\xA0\xE1\0\x50\xA0\x13\x6\0\0\x1A\x4\x10\x91\xE5\x4\x1\x9F\xE5\xAB\xFF\xFF\xEB">>$F +printf "\0\0\x50\xE3\x1\x50\xA0\x3\x2\x30\xA0\x3\x37\0\0\x1A\x3\x71\x94\xE7\a\x30\xA0\xE1\x3\x10\xA0\xE1\x1\x20\xD3\xE4\0\0\x52\xE3\x5\0\0\n\x2F\0\x52\xE3\xF9\xFF\xFF\x1A\0\x30\xA0\xE3\x1\x40\x81\xE2\0\x30\xC1\xE5\0\0\0\xEA\x2\x40\xA0\xE1\x10\x20\xA0\xE3\xB8\x10\x9F\xE5\b\0\x8D\xE2\x99\xFF\xFF\xEB\0\0\x55\xE3\x1\x60\xA0\x3\x2\0\0\n\b\0\x8D\xE2\xAC\xFF\xFF\xEB\0\x60\xA0\xE1\x4\x20\xA0\xE1\a\x10\xA0\xE1\x6\0\xA0\xE1\x7E\0\0\xEB\0\x40\x50\xE2\x13\0\0\x1A\0\0\x55\xE3\x5\0\xA0\x1\x1A\0\0\n\x5\x1D\xA0\xE3\x6\0\xA0\xE1\xC9\xFF\xFF\xEB\x6\0\xA0\xE1\xBE\xFF\xFF\xEB\xA8\xFF\xFF\xEB\0\x20\x50\xE2\x3\0\0\n\x4\0\x8D\xE2\x89\xFF\xFF\xEB\x5\x40\xDD\xE5\x6\0\0\xEA\b\x10\x8D\xE2\x1\0\xA0\xE1\x7E\xFF\xFF\xEB\x1">>$F +printf "\0\xA0\xE3\t\0\0\xEA\0\0\x55\xE3\x6\0\0\n\b\0\x8D\xE2\x7B\xFF\xFF\xEB\x3\0\0\xEA\x6\0\xA0\xE3\x1E\xFF\x2F\xE1\x6\0\xA0\xE3\0\0\0\xEA\x4\0\xA0\xE1\x1C\xD0\x8D\xE2\xF0\x80\xBD\xE8\x19\xF\x1\0\x1D\xF\x1\0\0\xB0\xA0\xE3\0\xE0\xA0\xE3\x4\x10\x9D\xE4\r\x20\xA0\xE1\x4\x20\x2D\xE5\x4\0\x2D\xE5\x10\xC0\x9F\xE5\x4\xC0\x2D\xE5\f\0\x9F\xE5\f\x30\x9F\xE5\x6F\xFF\xFF\xEB\x92\xFF\xFF\xEB\xE0\xE\x1\0\x5C\a\x1\0\x80\xE\x1\0\x14\x30\x9F\xE5\x14\x20\x9F\xE5\x3\x30\x8F\xE0\x2\x20\x93\xE7\0\0\x52\xE3\x1E\xFF\x2F\x1\x67\xFF\xFF\xEA\x10\b\x1\0\x74\0\0\0\x1C\x30\x9F\xE5\x1C\0\x9F\xE5\x3\x30\x60\xE0\x6\0\x53\xE3\x1E\xFF\x2F\x91\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x77\x11\x2\0\x74\x11\x2">>$F +printf "\0\0\0\0\0\x24\x10\x9F\xE5\x24\0\x9F\xE5\x1\x10\x60\xE0\x41\x11\xA0\xE1\xA1\x1F\x81\xE0\xC1\x10\xB0\xE1\x1E\xFF\x2F\x1\x10\x30\x9F\xE5\0\0\x53\xE3\x1E\xFF\x2F\x1\x13\xFF\x2F\xE1\x74\x11\x2\0\x74\x11\x2\0\0\0\0\0\x10\x40\x2D\xE9\x18\x40\x9F\xE5\0\x30\xD4\xE5\0\0\x53\xE3\x10\x80\xBD\x18\xDF\xFF\xFF\xEB\x1\x30\xA0\xE3\0\x30\xC4\xE5\x10\x80\xBD\xE8\x74\x11\x2\0\x28\0\x9F\xE5\0\x30\x90\xE5\0\0\x53\xE3\0\0\0\x1A\xE2\xFF\xFF\xEA\x18\x30\x9F\xE5\0\0\x53\xE3\xFB\xFF\xFF\n\x10\x40\x2D\xE9\x33\xFF\x2F\xE1\x10\x40\xBD\xE8\xDB\xFF\xFF\xEA\b\x10\x2\0\0\0\0\0\x4\xE0\x2D\xE5\x2\xDA\x4D\xE2\x14\xD0\x4D\xE2\x1\x3A\x8D\xE2\x10\x30\x83\xE2\0\x30\x8D\xE5\x10\x20\x8D\xE2\f\x30\x8D\xE2\x18\x10\x9F\xE5\x32\xFF">>$F +printf "\xFF\xEB\x1\0\x50\xE3\0\0\xE0\xD3\f\0\x9D\xC5\x2\xDA\x8D\xE2\x14\xD0\x8D\xE2\x4\xF0\x9D\xE4\xF0\xE\x1\0\xF0\x47\x2D\xE9\x1\xDA\x4D\xE2\x28\xD0\x4D\xE2\x28\x40\x8D\xE2\x1\x90\xA0\xE1\0\x80\xA0\xE1\0\x10\xA0\xE3\x2\xA0\xA0\xE1\b\0\x8D\xE2\x20\x20\xA0\xE3\x23\xFF\xFF\xEB\x1\x50\xA0\xE3\x24\x30\x44\xE2\b\x20\x8D\xE2\xDC\x11\x9F\xE5\t\0\xA0\xE1\x18\x50\x4\xE5\x28\xFF\xFF\xEB\0\0\x50\xE3\x24\x60\x14\x5\0\x70\xE0\x3\x68\0\0\x1A\0\0\x56\xE3\x10\0\0\n\a\0\x96\xE9\x23\xFF\xFF\xEB\0\x70\x50\xE2\t\0\0\xBA\x10\x20\x96\xE5\x14\x10\x96\xE5\x30\xFF\xFF\xEB\0\0\x50\xE3\a\0\0\xAA\a\0\xA0\xE1\x26\xFF\xFF\xEB\0\x70\xE0\xE3\x4\x50\xA0\xE3\0\0\0\xEA\x2\x50\xA0\xE3\x1C\x60\x96\xE5\xEC\xFF\xFF\xEA\x24\0\x14\xE5">>$F +printf "\f\xFF\xFF\xEB\0\0\x57\xE3\x53\0\0\xBA\x64\x11\x9F\xE5\x4\0\xA0\xE1\xEC\xFE\xFF\xEB\0\0\x5A\xE3\x6\0\0\n\x4\0\xA0\xE1\xF1\xFE\xFF\xEB\n\x10\xA0\xE1\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\a\xFF\xFF\xEB\x4\0\xA0\xE1\xEA\xFE\xFF\xEB\x30\x11\x9F\xE5\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\0\xFF\xFF\xEB\x4\0\xA0\xE1\xE3\xFE\xFF\xEB\t\x10\xA0\xE1\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xF9\xFE\xFF\xEB\x4\0\xA0\xE1\xDC\xFE\xFF\xEB\xFC\x10\x9F\xE5\xFF\x2E\x60\xE2\xF\x20\x82\xE2\x4\0\xA0\xE1\xF2\xFE\xFF\xEB\x4\0\xA0\xE1\xD5\xFE\xFF\xEB\0\x30\xA0\xE3\x4\x10\xA0\xE1\0\x50\xA0\xE1\0\x20\xA0\xE1\a\0\xA0\xE1\xF6\xFE\xFF\xEB\x5\0\x50\xE1\x1\x90\xA0\x3\x29\0\0\x1A\0\x30\xA0\xE3\x1\x2A\xA0">>$F +printf "\xE3\x4\x10\xA0\xE1\a\0\xA0\xE1\xE8\xFE\xFF\xEB\0\x50\x50\xE2\x20\0\0\n\0\0\x59\xE3\x16\0\0\n\x98\x10\x9F\xE5\x4\0\xA0\xE1\xA2\xFE\xFF\xEB\0\x60\x50\xE2\xF1\xFF\xFF\n\x4\0\xA0\xE1\x85\xFF\xFF\xEB\xC8\x30\x40\xE2\x63\0\x53\xE3\0\x90\xA0\xE1\a\0\0\x9A\x64\x10\xA0\xE3\x1B\0\0\xEB\n\x10\xA0\xE3\x91\0\x4\xE0\t\0\xA0\xE1\x9F\0\0\xEB\x1\0\x84\xE0\xE\0\0\xEA\x4\x10\x86\xE2\x4\x30\x61\xE0\x3\x50\x85\xE0\0\0\0\xEA\x4\x10\xA0\xE1\x5\x20\xA0\xE1\b\0\xA0\xE1\xB4\xFE\xFF\xEB\0\x90\xA0\xE3\xD9\xFF\xFF\xEA\x3\0\xA0\xE3\x2\0\0\xEA\x5\0\xA0\xE1\0\0\0\xEA\x5\0\xA0\xE3\x1\xDA\x8D\xE2\x28\xD0\x8D\xE2\xF0\x87\xBD\xE8\xF9\xE\x1\0\xFC\xE\x1\0\x2\xF\x1\0\x14\xF\x1\0\0\0\x51\xE3\x81\0\0\n\x1\xC0\x20\xE0\0\x10\x61">>$F +printf "\x42\x1\x20\x51\xE2\x70\0\0\n\0\x30\xB0\xE1\0\x30\x60\x42\x1\0\x53\xE1\x6F\0\0\x9A\x2\0\x11\xE1\x71\0\0\n\x13\x2F\x6F\xE1\x11\xF\x6F\xE1\x2\x20\x40\xE0\x1F\x20\x72\xE2\x82\x20\x82\x10\0\0\xA0\xE3\x2\xF1\x8F\x10\0\0\xA0\xE1\x81\xF\x53\xE1\0\0\xA0\xE0\x81\x3F\x43\x20\x1\xF\x53\xE1\0\0\xA0\xE0\x1\x3F\x43\x20\x81\xE\x53\xE1\0\0\xA0\xE0\x81\x3E\x43\x20\x1\xE\x53\xE1\0\0\xA0\xE0\x1\x3E\x43\x20\x81\r\x53\xE1\0\0\xA0\xE0\x81\x3D\x43\x20\x1\r\x53\xE1\0\0\xA0\xE0\x1\x3D\x43\x20\x81\f\x53\xE1\0\0\xA0\xE0\x81\x3C\x43\x20\x1\f\x53\xE1\0\0\xA0\xE0\x1\x3C\x43\x20\x81\v\x53\xE1\0\0\xA0\xE0\x81\x3B\x43\x20\x1\v\x53\xE1\0\0\xA0\xE0\x1\x3B\x43\x20\x81\n\x53\xE1\0\0\xA0\xE0\x81\x3A\x43\x20\x1\n\x53\xE1\0">>$F +printf "\0\xA0\xE0\x1\x3A\x43\x20\x81\t\x53\xE1\0\0\xA0\xE0\x81\x39\x43\x20\x1\t\x53\xE1\0\0\xA0\xE0\x1\x39\x43\x20\x81\b\x53\xE1\0\0\xA0\xE0\x81\x38\x43\x20\x1\b\x53\xE1\0\0\xA0\xE0\x1\x38\x43\x20\x81\a\x53\xE1\0\0\xA0\xE0\x81\x37\x43\x20\x1\a\x53\xE1\0\0\xA0\xE0\x1\x37\x43\x20\x81\x6\x53\xE1\0\0\xA0\xE0\x81\x36\x43\x20\x1\x6\x53\xE1\0\0\xA0\xE0\x1\x36\x43\x20\x81\x5\x53\xE1\0\0\xA0\xE0\x81\x35\x43\x20\x1\x5\x53\xE1\0\0\xA0\xE0\x1\x35\x43\x20\x81\x4\x53\xE1\0\0\xA0\xE0\x81\x34\x43\x20\x1\x4\x53\xE1\0\0\xA0\xE0\x1\x34\x43\x20\x81\x3\x53\xE1\0\0\xA0\xE0\x81\x33\x43\x20\x1\x3\x53\xE1\0\0\xA0\xE0\x1\x33\x43\x20\x81\x2\x53\xE1\0\0\xA0\xE0\x81\x32\x43\x20\x1\x2\x53\xE1\0\0\xA0\xE0\x1\x32\x43\x20\x81">>$F +printf "\x1\x53\xE1\0\0\xA0\xE0\x81\x31\x43\x20\x1\x1\x53\xE1\0\0\xA0\xE0\x1\x31\x43\x20\x81\0\x53\xE1\0\0\xA0\xE0\x81\x30\x43\x20\x1\0\x53\xE1\0\0\xA0\xE0\x1\x30\x43\x20\0\0\x5C\xE3\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x3C\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\xA0\x33\xCC\xF\xA0\x1\x1\0\x80\x3\x1E\xFF\x2F\xE1\x11\x2F\x6F\xE1\x1F\x20\x62\xE2\0\0\x5C\xE3\x33\x2\xA0\xE1\0\0\x60\x42\x1E\xFF\x2F\xE1\0\0\x50\xE3\x2\x1\xE0\xC3\x2\x1\xA0\xB3\a\0\0\xEA\0\0\x51\xE3\xF9\xFF\xFF\n\x3\x40\x2D\xE9\x75\xFF\xFF\xEB\x6\x40\xBD\xE8\x92\0\x3\xE0\x3\x10\x41\xE0\x1E\xFF\x2F\xE1\x2\x40\x2D\xE9\b\0\xA0\xE3\xEC\xFD\xFF\xEB\x2\x80\xBD\xE8\xF0\x47\x2D\xE9\x4C\x60\x9F\xE5\x4C\x50\x9F\xE5\x6\x60\x8F\xE0\x5\x50\x8F\xE0\x6\x60\x65">>$F +printf "\xE0\0\x70\xA0\xE1\x1\x80\xA0\xE1\x2\x90\xA0\xE1\xD6\xFD\xFF\xEB\x46\x61\xB0\xE1\xF0\x87\xBD\b\0\x40\xA0\xE3\x1\x40\x84\xE2\x4\x30\x95\xE4\t\x20\xA0\xE1\b\x10\xA0\xE1\a\0\xA0\xE1\x33\xFF\x2F\xE1\x6\0\x54\xE1\xF7\xFF\xFF\x1A\xF0\x87\xBD\xE8\x70\x1\x1\0\x68\x1\x1\0\x1E\xFF\x2F\xE1\b\x40\x2D\xE9\b\x80\xBD\xE8\x1\0\x2\0\x25\x73\x20\x25\x64\x20\x25\x73\0\x38\x30\0\x47\x45\x54\x20\x2F\0\x20\x48\x54\x54\x50\x2F\x31\x2E\x30\r\n\x48\x6F\x73\x74\x3A\x20\0\r\n\r\n\0\x72\x75\x6E\0\x2F\x74\x6D\x70\x2F\x75\x67\x65\x74\x58\x58\x58\x58\x58\x58\0\0\0\0\x68\xF9\xFF\x7F\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x88\t\x1\0\x60\t\x1\0\0\0\0\0\x1\0\0\0\x1\0\0\0\f\0\0\0\x4\x6\x1\0\r\0\0\0\xE4\xE\x1\0\x19\0\0\0\0\x10\x2\0\x1B\0\0\0\x4\0\0\0\x1A\0\0\0\x4\x10\x2\0\x1C\0\0\0\x4\0\0\0\x4\0\0\0\x68\x1\x1\0\x5\0\0\0\xD0\x3\x1\0\x6\0\0\0\x20\x2\x1\0\n\0\0\0\xF3\0\0\0\v\0\0\0\x10\0\0\0\x15\0\0\0\0\0\0\0\x3\0\0\0\xF4\x10\x2\0\x2\0\0\0\xD0\0\0\0\x14\0\0\0\x11\0\0\0\x17\0\0\0\x34\x5\x1\0\x11\0\0\0\x2C\x5\x1\0\x12\0\0\0\b\0\0\0\x13\0\0\0\b\0">>$F +printf "\0\0\xFE\xFF\xFF\x6F\xFC\x4\x1\0\xFF\xFF\xFF\x6F\x1\0\0\0\xF0\xFF\xFF\x6F\xC4\x4\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\f\x10\x2\0\0\0\0\0\0\0\0\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\x10\x6\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\x41\x31\0\0\0\x61\x65\x61\x62\x69\0\x1\x27\0\0\0\x5\x41\x52\x4D\x39\x32\x36\x45\x4A\x2D\x53\0\x6\x5\b\x1\t\x1\x12\x4\x13\x1\x14\x1\x15\x1\x17\x3\x18">>$F +printf "\x1\x19\x1\x1A\x2\0\x2E\x73\x68\x73\x74\x72\x74\x61\x62\0\x2E\x69\x6E\x74\x65\x72\x70\0\x2E\x68\x61\x73\x68\0\x2E\x64\x79\x6E\x73\x79\x6D\0\x2E\x64\x79\x6E\x73\x74\x72\0\x2E\x67\x6E\x75\x2E\x76\x65\x72\x73\x69\x6F\x6E\0\x2E\x67\x6E\x75\x2E\x76\x65\x72\x73\x69\x6F\x6E\x5F\x72\0\x2E\x72\x65\x6C\x2E\x64\x79\x6E\0\x2E\x72\x65\x6C\x2E\x70\x6C\x74\0\x2E\x69\x6E\x69\x74\0\x2E\x74\x65\x78\x74\0\x2E\x66\x69\x6E\x69\0\x2E\x72\x6F\x64\x61\x74\x61\0\x2E\x41\x52\x4D\x2E\x65\x78\x69\x64\x78\0\x2E\x65\x68\x5F\x66\x72\x61\x6D\x65\0\x2E\x69\x6E\x69\x74\x5F\x61\x72\x72\x61\x79\0\x2E\x66\x69\x6E\x69\x5F\x61\x72\x72\x61\x79\0\x2E\x6A\x63\x72\0\x2E\x64\x79\x6E\x61\x6D\x69\x63\0\x2E\x67\x6F\x74\0\x2E\x64\x61">>$F +printf "\x74\x61\0\x2E\x62\x73\x73\0\x2E\x41\x52\x4D\x2E\x61\x74\x74\x72\x69\x62\x75\x74\x65\x73\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\v\0\0\0\x1\0\0\0\x2\0\0\0\x34\x1\x1\0\x34\x1\0\0\x13\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x13\0\0\0\x5\0\0\0\x2\0\0\0\x68\x1\x1\0\x68\x1\0\0\xB8\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x19\0\0\0\v\0\0\0\x2\0\0\0\x20\x2\x1\0\x20\x2\0\0\xB0\x1\0\0\x4\0\0\0\x1\0\0\0\x4\0\0\0\x10\0\0\0\x21\0\0\0\x3\0\0\0\x2\0\0\0\xD0\x3\x1\0\xD0\x3\0\0\xF3\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x29\0\0\0\xFF\xFF\xFF\x6F\x2\0\0\0\xC4\x4\x1\0\xC4\x4\0\0\x36\0\0\0\x3\0\0\0\0\0\0\0\x2\0\0\0\x2\0\0\0\x36\0\0\0\xFE\xFF\xFF\x6F\x2\0\0\0\xFC">>$F +printf "\x4\x1\0\xFC\x4\0\0\x30\0\0\0\x4\0\0\0\x1\0\0\0\x4\0\0\0\0\0\0\0\x45\0\0\0\t\0\0\0\x2\0\0\0\x2C\x5\x1\0\x2C\x5\0\0\b\0\0\0\x3\0\0\0\0\0\0\0\x4\0\0\0\b\0\0\0\x4E\0\0\0\t\0\0\0\x42\0\0\0\x34\x5\x1\0\x34\x5\0\0\xD0\0\0\0\x3\0\0\0\n\0\0\0\x4\0\0\0\b\0\0\0\x57\0\0\0\x1\0\0\0\x6\0\0\0\x4\x6\x1\0\x4\x6\0\0\f\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x52\0\0\0\x1\0\0\0\x6\0\0\0\x10\x6\x1\0\x10\x6\0\0\x4C\x1\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\x5D\0\0\0\x1\0\0\0\x6\0\0\0\x5C\a\x1\0\x5C\a\0\0\x88\a\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x63\0\0\0\x1\0\0\0\x6\0\0\0\xE4\xE\x1\0\xE4\xE\0\0\b\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x69\0\0\0\x1\0\0\0\x2\0\0\0\xEC\xE\x1\0\xEC\xE\0\0\x41\0\0\0\0\0\0\0\0\0">>$F +printf "\0\0\x4\0\0\0\0\0\0\0\x71\0\0\0\x1\0\0\x70\x82\0\0\0\x30\xF\x1\0\x30\xF\0\0\b\0\0\0\v\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x7C\0\0\0\x1\0\0\0\x2\0\0\0\x38\xF\x1\0\x38\xF\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x86\0\0\0\xE\0\0\0\x3\0\0\0\0\x10\x2\0\0\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x92\0\0\0\xF\0\0\0\x3\0\0\0\x4\x10\x2\0\x4\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\x9E\0\0\0\x1\0\0\0\x3\0\0\0\b\x10\x2\0\b\x10\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\xA3\0\0\0\x6\0\0\0\x3\0\0\0\f\x10\x2\0\f\x10\0\0\xE8\0\0\0\x4\0\0\0\0\0\0\0\x4\0\0\0\b\0\0\0\xAC\0\0\0\x1\0\0\0\x3\0\0\0\xF4\x10\x2\0\xF4\x10\0\0\x78\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\x4\0\0\0\xB1\0\0\0\x1\0\0\0">>$F +printf "\x3\0\0\0\x6C\x11\x2\0\x6C\x11\0\0\b\0\0\0\0\0\0\0\0\0\0\0\x4\0\0\0\0\0\0\0\xB7\0\0\0\b\0\0\0\x3\0\0\0\x74\x11\x2\0\x74\x11\0\0\x4\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\xBC\0\0\0\x3\0\0\x70\0\0\0\0\0\0\0\0\x74\x11\0\0\x32\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x1\0\0\0\x3\0\0\0\0\0\0\0\0\0\0\0\xA6\x11\0\0\xCC\0\0\0\0\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0">>$F + diff --git a/frontend/apps/site/src/components/boards/BoardPanel.tsx b/frontend/apps/site/src/components/boards/BoardPanel.tsx index efe98349..3968119b 100644 --- a/frontend/apps/site/src/components/boards/BoardPanel.tsx +++ b/frontend/apps/site/src/components/boards/BoardPanel.tsx @@ -14,6 +14,7 @@ import type { BoardLink, LinkKind, ModelDetail, Source } from '../../lib/boards/ import { fetchModel } from '../../lib/boards/api'; import Firmware from './Firmware'; import ModelFirmware from './ModelFirmware'; +import OwnerReports from '../reports/OwnerReports'; import { HEADING_CLASS, couplerDevices, foundIn, frontPhoto, insideOf, firstMissing, formatBytes, heading, linkCodes, lines, paragraphs, printedCode, subtitle, unitFiles, unitPhotos, type CodeIndex, type Entry, type Heading, type Inside, @@ -271,6 +272,8 @@ export default function BoardPanel({ id, entry, all, loaded, locale, t, sources, )} + + {entry && (
diff --git a/frontend/apps/site/src/components/pages/Report.astro b/frontend/apps/site/src/components/pages/Report.astro new file mode 100644 index 00000000..a4893473 --- /dev/null +++ b/frontend/apps/site/src/components/pages/Report.astro @@ -0,0 +1,86 @@ +--- +/** + * How a new board reaches the catalogue: the owner's report, sent by ipctool + * from the camera or by an AI coding agent working on it, and -- with ?id= -- + * that report's receipt, which is the address ipctool prints. + * + * Stock camera firmware has no curl and no TLS, so the shell route never + * says curl: uget, pasted in as text over telnet, fetches ipctool from + * http://openipc.org/ipctool (plain HTTP, no redirect: uget follows none), + * and a firmware with an NFS client mounts the same files instead + * (service/internal/tools, service/internal/nfsro). + */ +import Terminal from '../Terminal.astro'; +import UgetCopy from '../reports/UgetCopy.tsx'; +import Receipt from '../reports/Receipt.tsx'; +import { useTranslations, type Locale } from '../../lib/i18n'; + +interface Props { locale: Locale } +const { locale } = Astro.props; +const t = useTranslations(locale); +const p = (key: string) => t(`pages.report.${key}`); +const flags: [string, string][] = [ + ['upload', p('flag_upload')], + ['--backup', p('flag_backup')], + ['--backup --public', p('flag_public')], + ['--note "…"', p('flag_note')], +]; +--- +
+

{p('eyebrow')}

+

{p('title')}

+

{p('lede')}

+ + + +
+
+

{p('shell_title')}

+

+ +

+

{p('step_fetch')}

+ /tmp/ipctool\nchmod +x /tmp/ipctool\n/tmp/ipctool upload'} /> +
+
+ {p('nfs_title')} + +

+

+
+ {flags.map(([flag, what]) => ( + <> +
{flag}
+
{what}
+ + ))} +
+
+ +
+

{p('agent_title')}

+

{p('agent_lede')}

+ +
    +
  1. +
  2. {p('agent_step2')}
  3. +
  4. +
+
+
+ +
+
+

{p('fact1_title')}

+

{p('fact1')}

+
+
+

{p('fact2_title')}

+

{p('fact2')}

+
+
+

{p('fact3_title')}

+

+

+
+
diff --git a/frontend/apps/site/src/components/reports/OwnerReports.tsx b/frontend/apps/site/src/components/reports/OwnerReports.tsx new file mode 100644 index 00000000..436903f4 --- /dev/null +++ b/frontend/apps/site/src/components/reports/OwnerReports.tsx @@ -0,0 +1,75 @@ +/** + * "Reports from owners" in a board's panel: the published reports filed + * under this board (service/internal/reports), each with what ipctool found, + * its files, and its YAML with the board's identifiers already replaced by + * keyed hashes. A private backup shows that it exists, never a download. + * Nothing is rendered for a board nobody has reported, beyond the ask. + */ +import { useEffect, useState } from 'preact/hooks'; +import type { BoardsT } from '../../lib/boards-i18n'; +import { pathFor, type Locale } from '../../lib/i18n'; +import { fetchBoardReports, size, summary, type Report } from '../../lib/reports'; + +export default function OwnerReports({ model, locale, t }: { model: string; locale: Locale; t: BoardsT }) { + const [reports, setReports] = useState(null); + + useEffect(() => { + let live = true; + setReports(null); + fetchBoardReports(model) + .then((v) => live && setReports(v.reports)) + .catch(() => live && setReports([])); + return () => { live = false; }; + }, [model]); + + const ask = ( +

+ {t('report.reports_ask')} {t('report.reports_send')}. {t('report.reports_ask_tail')} +

+ ); + if (reports === null) return null; + if (reports.length === 0) return ask; + + return ( +
+

+ {t('report.reports_title')} + {reports.length} +

+
+ {reports.map((r) => ( +
+ +
+ {r.facts?.main_app && stock app {r.facts.main_app}} + {r.facts?.board_model && board {r.facts.board_vendor} {r.facts.board_model}} + {r.same_board > 0 && {t('report.reports_same_board')}} +
+ {r.files.length > 0 && ( +
+ {r.files.map((f) => { + const label = `${t(`report.kind_${f.kind}`)} · ${size(f.bytes)}`; + return f.url + ? {label} + : {t(`report.kind_${f.kind}`)} · {t('report.who_private')}; + })} +
+ )} + {r.yaml && ( +
+ {t('report.reports_yaml')} +
{r.yaml}
+
+ )} +
+ ))} +
+ {ask} +
+ ); +} diff --git a/frontend/apps/site/src/components/reports/Receipt.tsx b/frontend/apps/site/src/components/reports/Receipt.tsx new file mode 100644 index 00000000..324b96e0 --- /dev/null +++ b/frontend/apps/site/src/components/reports/Receipt.tsx @@ -0,0 +1,142 @@ +/** + * A report's receipt, at /cameras/report/?id=r-xxxxxxxx: the address ipctool + * prints. It shows what arrived and who may see it; the report's content + * appears only once a maintainer has published it, and then on the board. + * With no ?id= it is the lookup form. + */ +import { useEffect, useState } from 'preact/hooks'; +import { useBoardsTranslations } from '../../lib/boards-i18n'; +import type { Locale } from '../../lib/i18n'; +import { NotFound, RECEIPT_ID, fetchReport, size, summary, type Report } from '../../lib/reports'; + +type Load = { state: 'none' } | { state: 'loading' } | { state: 'ok'; value: Report } | { state: 'missing' } | { state: 'error' }; + +const PILL = 'inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-[13px] font-semibold'; +const TONE: Record = { + pending: 'bg-[#fff4e0] text-[#8a5a00]', + published: 'bg-[#e7f5ee] text-[#1f7a4d]', + rejected: 'bg-surface-alt text-body-secondary', + withdrawn: 'bg-surface-alt text-body-secondary', +}; + +export default function Receipt({ locale }: { locale: Locale }) { + const t = useBoardsTranslations(locale); + const [id, setId] = useState(''); + const [load, setLoad] = useState({ state: 'none' }); + + useEffect(() => { + const q = new URLSearchParams(location.search).get('id')?.trim().toLowerCase() ?? ''; + setId(q); + if (!RECEIPT_ID.test(q)) return; + setLoad({ state: 'loading' }); + fetchReport(q) + .then((value) => setLoad({ state: 'ok', value })) + .catch((e) => setLoad({ state: e instanceof NotFound ? 'missing' : 'error' })); + }, []); + + const form = ( +
+ + + +
+ ); + + if (load.state === 'none') return form; + if (load.state === 'loading') return
; + if (load.state === 'missing' || load.state === 'error') { + return ( +
+ + {form} +
+ ); + } + + const r = load.value; + const when = new Date(r.received_at).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }); + const kind = (k: string) => t(`report.kind_${k}`); + const published = r.status === 'published'; + return ( +
+
+

{r.id}

+ {t(`report.status_${r.status}`)} + {r.backup_consent !== 'none' && ( + {t(`report.backup_${r.backup_consent}`)} + )} +
+ +
    + + + +
+ +
+ + + + + + + + + + + + + + + {r.files.map((f) => ( + + + + + + ))} + +
{t('report.th_what')}{t('report.th_size')}{t('report.th_who')}
{kind('report')}–{t('report.who_public')}
+ {f.url ? {kind(f.kind)} : kind(f.kind)} + {f.name} + {size(f.bytes)} + {f.private + ? {t('report.who_private')} + : t('report.who_public')} +
+
+ + {published && r.models.length > 0 && ( +

+ {t('report.filed_under')}{' '} + {r.models.map((m, i) => ( + {i > 0 && ', '}{m.manufacturer} {m.model} + ))} +

+ )} + {!published && r.guess && ( +

+ {t('report.looks_like', { board: `${r.guess.manufacturer} ${r.guess.model}` }).split(`${r.guess.manufacturer} ${r.guess.model}`) + .flatMap((part, i) => (i === 0 ? [part] : [{r.guess!.manufacturer} {r.guess!.model}, part]))} + {r.guess.why.join(' · ')} · {t('report.looks_like_why')} +

+ )} +
+ ); +} + +function Step({ done, now, label, hint }: { done?: boolean; now?: boolean; label: string; hint: string }) { + const dot = now ? 'border-accent bg-accent' : done ? 'border-ink bg-ink' : 'border-hairline bg-white'; + return ( +
  • +
  • + ); +} diff --git a/frontend/apps/site/src/components/reports/UgetCopy.tsx b/frontend/apps/site/src/components/reports/UgetCopy.tsx new file mode 100644 index 00000000..e8472947 --- /dev/null +++ b/frontend/apps/site/src/components/reports/UgetCopy.tsx @@ -0,0 +1,55 @@ +/** + * Step one of the shell route: uget, as the printf script its release ships, + * copied to the clipboard for pasting into telnet. The builds are dynamically + * linked, so the reader picks by the C library `ls /lib/ld-*` shows; within + * one library the builds are tried in turn. + */ +import { useState } from 'preact/hooks'; +import { useBoardsTranslations } from '../../lib/boards-i18n'; +import type { Locale } from '../../lib/i18n'; +import { UGET_BUILDS } from '../../lib/reports'; + +export default function UgetCopy({ locale, label }: { locale: Locale; label: string }) { + const t = useBoardsTranslations(locale); + const [libc, setLibc] = useState<'uclibc' | 'glibc'>('uclibc'); + const builds = UGET_BUILDS.filter((b) => b.libc === libc); + const [file, setFile] = useState(builds[0].file); + const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle'); + const chosen = builds.some((b) => b.file === file) ? file : builds[0].file; + + const copy = async () => { + try { + const r = await fetch(`/uget/${chosen}`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + await navigator.clipboard.writeText(await r.text()); + setState('copied'); + } catch { + setState('failed'); + } + }; + + return ( +
    + +
    + + + +
    + {state === 'copied' &&

    {t('report.copied')}

    } + {state === 'failed' && ( +

    {t('report.copy_failed')} {chosen}

    + )} +

    +

    + ); +} diff --git a/frontend/apps/site/src/i18n/boards.en.json b/frontend/apps/site/src/i18n/boards.en.json index e1c0fb3a..90737441 100644 --- a/frontend/apps/site/src/i18n/boards.en.json +++ b/frontend/apps/site/src/i18n/boards.en.json @@ -223,6 +223,51 @@ "query_short": "Type at least three characters to search.", "ready_because": "a transition image exists for device {ids}", "ready_because_label": "Supported by OpenIPC:", + "report": { + "backup_private": "Backup: private", + "backup_public": "Backup: shared", + "copied": "Copied: paste it into telnet", + "copy_failed": "Could not copy. Open the text and copy it by hand:", + "copy_uget": "Copy uget as text", + "filed_under": "Filed under", + "kind_backup": "Flash backup", + "kind_boot_log": "Boot log", + "kind_document": "Document", + "kind_note": "Note", + "kind_photo": "Photo", + "kind_report": "ipctool report", + "kind_uboot_env": "U-Boot environment", + "libc_glibc": "ls /lib/ld-* shows ld-linux.so.3", + "libc_uclibc": "ls /lib/ld-* shows ld-uClibc.so.0", + "load_failed": "The report could not be loaded. Try again in a minute.", + "looks_like": "Looks like {board} in the catalogue", + "looks_like_why": "not certain until reviewed", + "lookup_button": "Show its state", + "lookup_label": "Have a receipt?", + "not_found": "No report has this receipt. Check the address ipctool printed.", + "received": "Received from {channel}", + "reports_ask": "Have this board?", + "reports_ask_tail": "Two owners with the same board is how a variant gets noticed.", + "reports_same_board": "same board as another report", + "reports_send": "Send us its report", + "reports_title": "Reports from owners", + "reports_yaml": "ipctool's output", + "status_pending": "Waiting for review", + "status_published": "Published", + "status_rejected": "Not published", + "status_withdrawn": "Withdrawn", + "step_published": "Published on the board's page", + "step_published_hint": "Only the anonymised copy. A private backup stays private.", + "step_review": "A maintainer reads it and files it under its board", + "step_review_hint": "Usually within a few days. This page changes when that happens.", + "th_size": "Size", + "th_what": "What arrived", + "th_who": "Who can see it", + "uget_build": "Build", + "uget_hint_html": "uget is linked against the camera's own C library: run ls /lib/ld-* first and pick what it shows. The text rebuilds itself as /tmp/uget. If the camera says not found or crashes, try the next build.", + "who_private": "OpenIPC's maintainers only", + "who_public": "Everyone, once published, with identifiers hashed" + }, "scope_all": "All", "scope_boot_log": "Boot logs", "scope_label": "Search in", diff --git a/frontend/apps/site/src/i18n/boards.ru.json b/frontend/apps/site/src/i18n/boards.ru.json index 7bed40aa..19a5efcd 100644 --- a/frontend/apps/site/src/i18n/boards.ru.json +++ b/frontend/apps/site/src/i18n/boards.ru.json @@ -245,6 +245,51 @@ "query_short": "Для поиска введите хотя бы три символа.", "ready_because": "есть образ перехода для устройства {ids}", "ready_because_label": "Поддерживается OpenIPC:", + "report": { + "backup_private": "Бэкап: закрытый", + "backup_public": "Бэкап: открытый", + "copied": "Скопировано: вставьте в telnet", + "copy_failed": "Не удалось скопировать. Откройте текст и скопируйте вручную:", + "copy_uget": "Скопировать uget как текст", + "filed_under": "Привязан к", + "kind_backup": "Бэкап флеша", + "kind_boot_log": "Лог загрузки", + "kind_document": "Документ", + "kind_note": "Заметка", + "kind_photo": "Фото", + "kind_report": "Отчёт ipctool", + "kind_uboot_env": "Окружение U-Boot", + "libc_glibc": "ls /lib/ld-* показывает ld-linux.so.3", + "libc_uclibc": "ls /lib/ld-* показывает ld-uClibc.so.0", + "load_failed": "Не удалось загрузить отчёт. Попробуйте через минуту.", + "looks_like": "Похоже на {board} из каталога", + "looks_like_why": "точно станет ясно после проверки", + "lookup_button": "Показать состояние", + "lookup_label": "Есть квитанция?", + "not_found": "Отчёта с такой квитанцией нет. Проверьте адрес, который напечатал ipctool.", + "received": "Получен от {channel}", + "reports_ask": "Есть такая плата?", + "reports_ask_tail": "Когда у двух владельцев одна плата, так и замечают новые варианты.", + "reports_same_board": "та же плата, что в другом отчёте", + "reports_send": "Пришлите её отчёт", + "reports_title": "Отчёты владельцев", + "reports_yaml": "Вывод ipctool", + "status_pending": "Ждёт проверки", + "status_published": "Опубликован", + "status_rejected": "Не опубликован", + "status_withdrawn": "Отозван", + "step_published": "Опубликован на странице платы", + "step_published_hint": "Только анонимизированная копия. Закрытый бэкап остаётся закрытым.", + "step_review": "Мейнтейнер читает отчёт и привязывает его к плате", + "step_review_hint": "Обычно за несколько дней. Эта страница изменится, когда это случится.", + "th_size": "Размер", + "th_what": "Что пришло", + "th_who": "Кому видно", + "uget_build": "Сборка", + "uget_hint_html": "uget собран под C-библиотеку самой камеры: сначала выполните ls /lib/ld-* и выберите то, что она показывает. Текст сам соберётся в /tmp/uget. Если камера ответит not found или упадёт — попробуйте следующую сборку.", + "who_private": "Только мейнтейнерам OpenIPC", + "who_public": "Всем после публикации, идентификаторы заменены хешами" + }, "scope_all": "Везде", "scope_boot_log": "Логи загрузки", "scope_label": "Где искать", diff --git a/frontend/apps/site/src/i18n/boards.zh.json b/frontend/apps/site/src/i18n/boards.zh.json index 4906bd08..9c853f96 100644 --- a/frontend/apps/site/src/i18n/boards.zh.json +++ b/frontend/apps/site/src/i18n/boards.zh.json @@ -223,6 +223,51 @@ "query_short": "请至少输入三个字符再搜索。", "ready_because": "设备 {ids} 有迁移镜像", "ready_because_label": "OpenIPC 支持:", + "report": { + "backup_private": "备份:不公开", + "backup_public": "备份:公开", + "copied": "已复制:粘贴到 telnet 中", + "copy_failed": "无法复制。请打开文本手动复制:", + "copy_uget": "以文本形式复制 uget", + "filed_under": "归属", + "kind_backup": "闪存备份", + "kind_boot_log": "启动日志", + "kind_document": "文档", + "kind_note": "备注", + "kind_photo": "照片", + "kind_report": "ipctool 报告", + "kind_uboot_env": "U-Boot 环境变量", + "libc_glibc": "ls /lib/ld-* 显示 ld-linux.so.3", + "libc_uclibc": "ls /lib/ld-* 显示 ld-uClibc.so.0", + "load_failed": "无法加载报告。请稍后再试。", + "looks_like": "看起来像目录中的 {board}", + "looks_like_why": "审核后才能确定", + "lookup_button": "查看状态", + "lookup_label": "有回执?", + "not_found": "没有与此回执对应的报告。请检查 ipctool 打印的地址。", + "received": "来自 {channel}", + "reports_ask": "有这块板?", + "reports_ask_tail": "两位用户拥有同一块板,新的变体就是这样被发现的。", + "reports_same_board": "与另一份报告是同一块板", + "reports_send": "发送它的报告", + "reports_title": "用户报告", + "reports_yaml": "ipctool 的输出", + "status_pending": "等待审核", + "status_published": "已发布", + "status_rejected": "未发布", + "status_withdrawn": "已撤回", + "step_published": "发布在电路板页面", + "step_published_hint": "仅发布匿名副本。不公开的备份保持不公开。", + "step_review": "维护者阅读报告并归到对应电路板下", + "step_review_hint": "通常几天之内。完成后此页面会更新。", + "th_size": "大小", + "th_what": "收到的内容", + "th_who": "谁能看到", + "uget_build": "版本", + "uget_hint_html": "uget 链接的是摄像头自己的 C 库:先运行 ls /lib/ld-*,按显示结果选择。文本会自行还原为 /tmp/uget。如果摄像头提示 not found 或崩溃,请换下一个版本。", + "who_private": "仅 OpenIPC 维护者", + "who_public": "发布后所有人可见,标识符已替换为哈希" + }, "scope_all": "全部", "scope_boot_log": "启动日志", "scope_label": "搜索范围", diff --git a/frontend/apps/site/src/i18n/en.json b/frontend/apps/site/src/i18n/en.json index 35b5da7e..a365d494 100644 --- a/frontend/apps/site/src/i18n/en.json +++ b/frontend/apps/site/src/i18n/en.json @@ -612,6 +612,33 @@ "reset_form": "Reset form", "title": "Wireless Network QR Code Generator" }, + "report": { + "agent_lede": "Give it this page. It checks the catalogue first, then sends what it found as a report of its own.", + "agent_prompt": "Read https://openipc.org/agents.md and follow it to identify this camera and send openipc.org a report.", + "agent_step1_html": "Ask POST /api/v1/boards/identify with ipctool's output: if the board is known, stop there, with its firmware.", + "agent_step2": "Collect the boot log, the U-Boot environment and, with the owner's say-so, a flash read.", + "agent_step3_html": "Send it all to POST /api/v1/reports and pass the receipt back to you.", + "agent_title": "An AI coding agent is doing the digging", + "eyebrow": "Board catalogue", + "fact1": "OpenIPC's maintainers review each report and file it under its board. Until then, only you can see it, at its receipt.", + "fact1_title": "Read before it is shown", + "fact2": "The MAC, the chip's serial and the cloud ID are replaced with hashes in everything published, in the report and in any log.", + "fact2_title": "Your camera stays anonymous", + "fact3_html": "It holds everything the camera stores, including Wi-Fi keys and passwords. It is published only if you sent it with --public.", + "fact3_title": "A backup is private unless you say otherwise", + "flag_backup": "adds the whole flash, kept private for OpenIPC's maintainers. ipctool asks you to type yes first.", + "flag_note": "where you bought it and what it is sold as", + "flag_public": "the backup is published with the report, once reviewed", + "flag_upload": "the hardware report: SoC, sensor, flash layout, firmware versions", + "lede": "A camera nobody has reported yet reaches the catalogue from one report: what ipctool reads off the board and, if you want to help port OpenIPC to it, the flash it came with.", + "nfs_hint_html": "Other ways in, over UART, TFTP or an SD card: ipctool's README.", + "nfs_title": "The firmware has an NFS client (most Xiongmai do)", + "shell_lede_html": "Telnet into the stock firmware, or a UART console. Stock firmware has no curl, so ipctool comes over plain HTTP from openipc.org, fetched by uget: a 5 KB downloader you paste in as text.", + "shell_title": "You have a shell on the camera", + "step_fetch": "2. Fetch ipctool and send the report", + "step_uget": "1. Paste uget into the telnet session", + "title": "Send us a board" + }, "reverse_engineering": { "bring1_text": "Kernel drivers and firmware you can build, patch and carry to the next kernel. We replace the binary function by function and prove parity by comparing traces.", "bring1_title": "Blobs become source", @@ -927,7 +954,7 @@ "buy_text": "These manufacturers and integrators ship cameras with OpenIPC on them. Plug one in and open its web interface.", "buy_title": "Buy one with OpenIPC already installed", "buy_when": "Easiest · no tools", - "experts_html": "Reverse engineering a new camera? Get a shell on it and run ipctool, or have an AI coding agent dig through the vendor firmware, then send us the report. That is how new boards reach this list.", + "experts_html": "Reverse engineering a new camera? Get a shell on it and run ipctool, or have an AI coding agent dig through the vendor firmware, then send us the report. That is how new boards reach this list.", "home_all": "All supported hardware", "home_lede": "You don’t need to know what a SoC is, and none of these needs a soldering iron.", "home_title": "Four ways to get started, easiest first", diff --git a/frontend/apps/site/src/i18n/ru.json b/frontend/apps/site/src/i18n/ru.json index 320b3252..beb0b7d7 100644 --- a/frontend/apps/site/src/i18n/ru.json +++ b/frontend/apps/site/src/i18n/ru.json @@ -618,6 +618,33 @@ "reset_form": "Сбросить форму", "title": "Генератор QR-кода беспроводной сети" }, + "report": { + "agent_lede": "Дайте ему эту страницу. Он сначала проверит каталог, а потом пришлёт найденное отдельным отчётом.", + "agent_prompt": "Read https://openipc.org/agents.md and follow it to identify this camera and send openipc.org a report.", + "agent_step1_html": "Спросить POST /api/v1/boards/identify с выводом ipctool: если плата известна — остановиться и взять её прошивку.", + "agent_step2": "Собрать лог загрузки, окружение U-Boot и, с согласия владельца, чтение флеша.", + "agent_step3_html": "Отправить всё в POST /api/v1/reports и вернуть вам квитанцию.", + "agent_title": "Разбирается ИИ-агент для программирования", + "eyebrow": "Каталог плат", + "fact1": "Мейнтейнеры OpenIPC просматривают каждый отчёт и привязывают его к плате. До этого он виден только вам — по квитанции.", + "fact1_title": "Проверяется до публикации", + "fact2": "MAC, серийный номер чипа и облачный ID во всём опубликованном заменены хешами — и в отчёте, и в логах.", + "fact2_title": "Ваша камера остаётся анонимной", + "fact3_html": "В нём всё, что хранит камера, включая ключи Wi-Fi и пароли. Он публикуется, только если вы отправили его с --public.", + "fact3_title": "Бэкап закрыт, если вы не решите иначе", + "flag_backup": "добавляет весь флеш, доступный только мейнтейнерам OpenIPC. Сначала ipctool попросит ввести yes.", + "flag_note": "где куплена камера и под каким названием продаётся", + "flag_public": "бэкап публикуется вместе с отчётом после проверки", + "flag_upload": "отчёт о железе: SoC, сенсор, разметка флеша, версии прошивки", + "lede": "Камера, о которой ещё никто не сообщил, попадает в каталог по одному отчёту: что ipctool прочитал с платы, а если хотите помочь с портированием OpenIPC — ещё и заводская прошивка с флеша.", + "nfs_hint_html": "Другие способы — через UART, TFTP или SD-карту: README ipctool.", + "nfs_title": "В прошивке есть NFS-клиент (у большинства Xiongmai есть)", + "shell_lede_html": "Telnet в заводскую прошивку или консоль UART. В заводских прошивках нет curl, поэтому ipctool скачивается с openipc.org по обычному HTTP утилитой uget: это загрузчик на 5 КБ, который вставляется в сессию как текст.", + "shell_title": "У вас есть shell на камере", + "step_fetch": "2. Скачайте ipctool и отправьте отчёт", + "step_uget": "1. Вставьте uget в telnet-сессию", + "title": "Прислать плату" + }, "reverse_engineering": { "bring1_text": "Ядерные драйверы и прошивки, которые можно собрать, пропатчить и перенести на следующее ядро. Мы заменяем бинарник функция за функцией и доказываем эквивалентность сравнением трасс.", "bring1_title": "Блобы становятся исходниками", @@ -933,7 +960,7 @@ "buy_text": "Эти производители и интеграторы продают камеры с OpenIPC. Включите камеру и откройте её веб-интерфейс.", "buy_title": "Купить камеру с уже установленной OpenIPC", "buy_when": "Проще всего · без инструментов", - "experts_html": "Исследуете новую камеру? Получите на ней консоль и запустите ipctool или поручите ИИ-агенту разобрать прошивку производителя, а затем пришлите нам отчёт. Так новые платы и попадают в этот список.", + "experts_html": "Исследуете новую камеру? Получите на ней консоль и запустите ipctool или поручите ИИ-агенту разобрать прошивку производителя, а затем пришлите нам отчёт. Так новые платы и попадают в этот список.", "home_all": "Всё поддерживаемое оборудование", "home_lede": "Знать, что такое SoC, не нужно, и паяльник не понадобится ни в одном из них.", "home_title": "Четыре способа начать, от самого простого", diff --git a/frontend/apps/site/src/i18n/zh.json b/frontend/apps/site/src/i18n/zh.json index d7500a6e..86d79c0b 100644 --- a/frontend/apps/site/src/i18n/zh.json +++ b/frontend/apps/site/src/i18n/zh.json @@ -612,6 +612,33 @@ "reset_form": "重置表格", "title": "无线网络二维码生成器" }, + "report": { + "agent_lede": "把这个页面交给它。它会先查目录,再把发现的内容作为单独的报告发送。", + "agent_prompt": "Read https://openipc.org/agents.md and follow it to identify this camera and send openipc.org a report.", + "agent_step1_html": "用 ipctool 的输出请求 POST /api/v1/boards/identify:如果电路板已知,就到此为止,直接使用它的固件。", + "agent_step2": "收集启动日志、U-Boot 环境变量,并在所有者同意后读取闪存。", + "agent_step3_html": "全部发送到 POST /api/v1/reports,并把回执交给您。", + "agent_title": "由 AI 编程代理来分析", + "eyebrow": "电路板目录", + "fact1": "OpenIPC 维护者会审核每份报告并归到对应的电路板下。在此之前,只有您能通过回执查看。", + "fact1_title": "先审核,后公开", + "fact2": "在所有公开内容中(报告和日志),MAC、芯片序列号和云 ID 都会被替换为哈希值。", + "fact2_title": "您的摄像头保持匿名", + "fact3_html": "备份包含摄像头存储的一切,包括 Wi-Fi 密钥和密码。只有使用 --public 发送时才会公开。", + "fact3_title": "备份默认不公开", + "flag_backup": "附带整个闪存,仅 OpenIPC 维护者可见。ipctool 会先要求您输入 yes。", + "flag_note": "购买渠道和销售名称", + "flag_public": "审核后,备份随报告一起公开", + "flag_upload": "硬件报告:SoC、传感器、闪存分区、固件版本", + "lede": "一台还没人报告过的摄像头,只需一份报告就能进入目录:ipctool 从板子上读到的信息;如果您愿意帮助移植 OpenIPC,再附上出厂闪存。", + "nfs_hint_html": "其他途径(UART、TFTP 或 SD 卡):见 ipctool 的 README。", + "nfs_title": "固件带 NFS 客户端(大多数雄迈固件都有)", + "shell_lede_html": "通过 telnet 进入原厂固件,或使用 UART 控制台。原厂固件没有 curl,所以 ipctool 由 uget 通过普通 HTTP 从 openipc.org 下载:uget 是一个 5 KB 的下载器,以文本形式粘贴进去即可。", + "shell_title": "您能进入摄像头的 shell", + "step_fetch": "2. 下载 ipctool 并发送报告", + "step_uget": "1. 把 uget 粘贴到 telnet 会话中", + "title": "提交电路板" + }, "reverse_engineering": { "bring1_text": "可以构建、打补丁并带到下一个内核的内核驱动与固件。我们逐个函数替换二进制,并用跟踪比对证明等价。", "bring1_title": "二进制块变成源码", @@ -927,7 +954,7 @@ "buy_text": "这些制造商和集成商出售预装 OpenIPC 的摄像机。接上电,打开它的网页界面即可。", "buy_title": "购买预装 OpenIPC 的摄像机", "buy_when": "最简单 · 无需工具", - "experts_html": "在逆向一台新摄像机?拿到它的命令行后运行 ipctool,或者让 AI 编程助手去分析厂商固件,然后把报告发给我们。新的主板就是这样进入这个列表的。", + "experts_html": "在逆向一台新摄像机?拿到它的命令行后运行 ipctool,或者让 AI 编程助手去分析厂商固件,然后把报告发给我们。新的主板就是这样进入这个列表的。", "home_all": "全部支持的硬件", "home_lede": "你不需要知道什么是 SoC,而且都不需要烙铁。", "home_title": "四种入门方式,从最简单的开始", diff --git a/frontend/apps/site/src/lib/page-paths.ts b/frontend/apps/site/src/lib/page-paths.ts index cfe30a30..6a4efa2c 100644 --- a/frontend/apps/site/src/lib/page-paths.ts +++ b/frontend/apps/site/src/lib/page-paths.ts @@ -89,6 +89,8 @@ export const PAGE_PATHS: PagePath[] = [ // Camera boards from real cameras, read at runtime from /api/v1/boards. { path: '/cameras/boards', titleKey: 'pages.boards.title', descriptionKey: 'pages.boards.lede' }, + // How a new board reaches the catalogue: ipctool or an agent, and a report's receipt (?id=). + { path: '/cameras/report', titleKey: 'pages.report.title', descriptionKey: 'pages.report.lede' }, { path: '/business', titleKey: 'pages.business.title' }, { path: '/community', titleKey: 'pages.community.title' }, diff --git a/frontend/apps/site/src/lib/pages.ts b/frontend/apps/site/src/lib/pages.ts index 8d5a2e13..b4e29b98 100644 --- a/frontend/apps/site/src/lib/pages.ts +++ b/frontend/apps/site/src/lib/pages.ts @@ -18,6 +18,7 @@ import Service from '../components/Service.astro'; import Smoke from '../components/Smoke.astro'; import Wall from '../components/pages/Wall.astro'; import Boards from '../components/pages/Boards.astro'; +import Report from '../components/pages/Report.astro'; import Business from '../components/pages/Business.astro'; import Community from '../components/pages/Community.astro'; import Donate from '../components/pages/Donate.astro'; @@ -90,6 +91,7 @@ const COMPONENTS: Record = { // The board catalogue, beside the SoC catalogue it links into. '/cameras/boards': { component: Boards }, + '/cameras/report': { component: Report }, '/business': { component: Business }, '/community': { component: Community }, diff --git a/frontend/apps/site/src/lib/reports.ts b/frontend/apps/site/src/lib/reports.ts new file mode 100644 index 00000000..5443773e --- /dev/null +++ b/frontend/apps/site/src/lib/reports.ts @@ -0,0 +1,84 @@ +/** + * Owner reports, as the site's own API answers them (service/internal/reports): + * a receipt at /api/v1/reports/{id}, and a board's published reports at + * /api/v1/reports?model=. Identifiers in them are keyed hashes already. + */ +export type ReportState = 'pending' | 'published' | 'rejected' | 'withdrawn'; + +export interface ReportFile { + kind: 'backup' | 'photo' | 'boot_log' | 'uboot_env' | 'note' | 'document'; + name: string; + bytes: number; + sha256?: string; + url?: string; + private?: boolean; +} + +export interface ReportFacts { + chip_vendor?: string; + chip_model?: string; + sensor?: string; + flash_id?: string; + flash_size?: string; + board_vendor?: string; + board_model?: string; + main_app?: string; +} + +export interface Report { + id: string; + received_at: string; + channel: 'ipctool' | 'agent' | 'web'; + status: ReportState; + reviewed_at?: string; + backup_consent: 'none' | 'private' | 'public'; + facts?: ReportFacts; + yaml?: string; + tool?: string; + note?: string; + files: ReportFile[]; + models: { id: string; model: string; manufacturer: string }[]; + same_board: number; + guess?: { model_id: string; model: string; manufacturer: string; url: string; why: string[] }; +} + +export const RECEIPT_ID = /^r-[a-z0-9]{8}$/; + +export class NotFound extends Error {} + +async function json(url: string): Promise { + const r = await fetch(url, { headers: { Accept: 'application/json' } }); + if (r.status === 404) throw new NotFound(url); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return (await r.json()) as T; +} + +export function fetchReport(id: string): Promise { + return json(`/api/v1/reports/${encodeURIComponent(id)}`); +} + +export function fetchBoardReports(model: string): Promise<{ reports: Report[] }> { + return json<{ reports: Report[] }>(`/api/v1/reports?${new URLSearchParams({ model }).toString()}`); +} + +/** "HiSilicon 3516CV300 · Sony IMX291 · 8M" */ +export function summary(f: ReportFacts | undefined): string { + if (!f) return ''; + return [[f.chip_vendor, f.chip_model].filter(Boolean).join(' '), f.sensor, f.flash_size].filter(Boolean).join(' · '); +} + +export function size(bytes: number): string { + if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} + +/** uget's release builds (OpenIPC/uget v0.1.0), vendored under /uget/, by the C library each links. */ +export const UGET_BUILDS: { file: string; libc: 'uclibc' | 'glibc'; toolchain: string }[] = [ + { file: 'uget.arm-himix100-linux.sh', libc: 'uclibc', toolchain: 'himix100' }, + { file: 'uget.arm-hisiv500-linux.sh', libc: 'uclibc', toolchain: 'hisiv500' }, + { file: 'uget.arm-hisiv510-linux.sh', libc: 'uclibc', toolchain: 'hisiv510' }, + { file: 'uget.arm-hisiv300-linux.sh', libc: 'uclibc', toolchain: 'hisiv300' }, + { file: 'uget.arm-himix200-linux.sh', libc: 'glibc', toolchain: 'himix200' }, + { file: 'uget.arm-hisiv600-linux.sh', libc: 'glibc', toolchain: 'hisiv600' }, +]; diff --git a/service/cmd/openipc/main.go b/service/cmd/openipc/main.go index bf9908d9..e9cc7246 100644 --- a/service/cmd/openipc/main.go +++ b/service/cmd/openipc/main.go @@ -166,6 +166,7 @@ var routes = []Route{ {"web", "POST", "/api/v1/vendor-firmware"}, {"web", "GET", "/api/v1/vendor-firmware/{deviceId}"}, {"web", "POST", "/api/v1/reports"}, + {"web", "GET", "/api/v1/reports"}, {"web", "GET", "/api/v1/reports/{id}"}, {"web", "GET", "/api/v1/reports/{id}/files/{position}"}, {"web", "POST", "/api/v1/boards/identify"}, diff --git a/service/internal/reports/api_test.go b/service/internal/reports/api_test.go index 5836c73a..fae1e6d3 100644 --- a/service/internal/reports/api_test.go +++ b/service/internal/reports/api_test.go @@ -121,6 +121,9 @@ func TestAReportIsAReceiptUntilPublishedAndThenNamesNoCamera(t *testing.T) { if v["status"] != "pending" || v["yaml"] != nil || v["facts"] != nil { t.Errorf("an unreviewed report shows its content: %v", v) } + if g, _ := v["guess"].(map[string]any); g == nil || g["model_id"] != "xiongmai-50h20l" { + t.Errorf("the receipt does not say which board it looks like: %v", v["guess"]) + } if rec, _ := e.get(t, "/api/v1/reports/"+id+"/files/2"); rec.Code != 404 { t.Errorf("an unreviewed report's photo: %d", rec.Code) } @@ -389,3 +392,29 @@ func TestANoteFieldIsTheReportsNoteAndANoteFileIsAFile(t *testing.T) { t.Errorf("note %q", r.Note) } } + +func TestABoardListsOnlyItsPublishedReports(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + st := &Store{DB: e.pool} + yaml := fixture(t, "xiongmai-50h20l-readme.yml") + var ids []string + for i := 0; i < 3; i++ { + _, out := e.post(t, upload{fields: map[string]string{"yaml": yaml}}, "203.0.113.20") + ids = append(ids, out["id"].(string)) + _ = st.Link(ctx, ids[i], "xiongmai-50h20l", "test") + } + _ = st.Review(ctx, ids[0], "publish", "test", "") + _ = st.Review(ctx, ids[1], "reject", "test", "") + rec, out := e.get(t, "/api/v1/reports?model=xiongmai-50h20l") + reports := out["reports"].([]any) + if rec.Code != 200 || len(reports) != 1 || reports[0].(map[string]any)["id"] != ids[0] { + t.Errorf("%d %s", rec.Code, rec.Body) + } + if strings.Contains(rec.Body.String(), "3beae2b40d84f889") { + t.Error("the cloud ID is in the board's list") + } + if rec, _ := e.get(t, "/api/v1/reports?model="); rec.Code != 400 { + t.Errorf("no model: %d", rec.Code) + } +} diff --git a/service/internal/reports/handler.go b/service/internal/reports/handler.go index bec926d1..574f194e 100644 --- a/service/internal/reports/handler.go +++ b/service/internal/reports/handler.go @@ -45,6 +45,7 @@ type API struct { func (a *API) Handlers() map[string]http.Handler { return map[string]http.Handler{ "POST /api/v1/reports": http.HandlerFunc(a.upload), + "GET /api/v1/reports": http.HandlerFunc(a.forModel), "GET /api/v1/reports/{id}": http.HandlerFunc(a.view), "GET /api/v1/reports/{id}/files/{position}": http.HandlerFunc(a.file), "POST /api/v1/boards/identify": http.HandlerFunc(a.identify), @@ -418,6 +419,24 @@ func (a *API) view(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, v) } +// forModel is GET /api/v1/reports?model=: the board's +// published reports, for its panel. +func (a *API) forModel(w http.ResponseWriter, r *http.Request) { + model := r.URL.Query().Get("model") + if model == "" || len(model) > 200 { + a.refuse(w, http.StatusBadRequest, "?model= names a board of the catalogue") + return + } + vs, err := a.store().ForModel(r.Context(), model) + if err != nil { + a.fail(w, "the board's reports", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=60") + _ = json.NewEncoder(w).Encode(map[string]any{"schema": 1, "model": model, "reports": vs}) +} + // file is GET /api/v1/reports/{id}/files/{position}: a published report's // file, handed to nginx to send. A private backup, or any file of an // unpublished report, is a 404 -- the same as one that does not exist. diff --git a/service/internal/reports/view.go b/service/internal/reports/view.go index 06bca07c..f5cc2f7c 100644 --- a/service/internal/reports/view.go +++ b/service/internal/reports/view.go @@ -29,6 +29,9 @@ type View struct { // reports come from the same physical board. Models []ViewModel `json:"models"` SameBoard int `json:"same_board"` + // Guess is, while a report waits for review, the catalogue board it most + // likely is -- a name from the catalogue, nothing from the report. + Guess *Match `json:"guess,omitempty"` } type ViewFile struct { @@ -98,6 +101,11 @@ func (s *Store) Public(ctx context.Context, id string) (*View, error) { return nil, err } if !published { + if v.State == "pending" { + if id, err := Identify(ctx, s.DB, f); err == nil && len(id.Matches) > 0 { + v.Guess = &id.Matches[0] + } + } return v, nil } mrows, err := s.DB.Query(ctx, ` @@ -128,6 +136,32 @@ func (s *Store) Public(ctx context.Context, id string) (*View, error) { return v, nil } +// ForModel is every published report linked to one board, newest first: +// what the board's panel lists. +func (s *Store) ForModel(ctx context.Context, model string) ([]*View, error) { + rows, err := s.DB.Query(ctx, ` + SELECT r.id FROM reports r JOIN report_models rm ON rm.report_id = r.id + WHERE rm.model_id = $1 + AND (SELECT decision FROM report_reviews rv WHERE rv.report_id = r.id ORDER BY rv.id DESC LIMIT 1) = 'publish' + ORDER BY r.received_at DESC LIMIT 50`, model) + if err != nil { + return nil, err + } + ids, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil { + return nil, err + } + out := []*View{} + for _, id := range ids { + v, err := s.Public(ctx, id) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil +} + // Served is a published report's file, for the download handler: the stored // copy that may be served, or ErrNotFound. func (s *Store) Served(ctx context.Context, id string, position int) (sum, name, mime, kind string, err error) { diff --git a/service/routes.json b/service/routes.json index 76e647e7..a7c45bdd 100644 --- a/service/routes.json +++ b/service/routes.json @@ -114,6 +114,11 @@ "method": "POST", "path": "/api/v1/reports" }, + { + "role": "web", + "method": "GET", + "path": "/api/v1/reports" + }, { "role": "web", "method": "GET", From d6c7707d0482c400557d12606760fc0a7adb54c7 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 29 Sep 2026 12:48:53 +0000 Subject: [PATCH 08/10] agents.md: the protocol an AI coding agent follows to identify a camera and report it Served from the bundle at /agents.md, as text/markdown (nginx has no .md type, and octet-stream reads as a download to a fetch tool). Rules first: the owner's camera only, no password guessing, the owner's say-so before a flash read. Then ipctool by uget or NFS, identify, what to collect, the upload and its refusals, the receipt. The upload now refuses a tool field over 200 characters with a 400. The column holds 200, and a longer one was a 500. --- deploy/nginx/sites-available/org.openipc | 12 ++ deploy/nginx/sites-available/org.openipc.dev | 12 ++ frontend/apps/site/public/agents.md | 141 +++++++++++++++++++ service/internal/reports/handler.go | 4 + 4 files changed, 169 insertions(+) create mode 100644 frontend/apps/site/public/agents.md diff --git a/deploy/nginx/sites-available/org.openipc b/deploy/nginx/sites-available/org.openipc index 7b1df11d..0253dfa1 100644 --- a/deploy/nginx/sites-available/org.openipc +++ b/deploy/nginx/sites-available/org.openipc @@ -917,6 +917,18 @@ server { add_header Cache-Control "public, max-age=31536000, immutable" always; } + # The protocol page for AI coding agents (/cameras/report links it), as + # Markdown text: nginx's types have no .md, and an octet-stream answer + # reads as a download to a fetch tool. + location = /agents.md { + root /srv/www/static/prod/current; + default_type "text/markdown; charset=utf-8"; + try_files $uri =404; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By static always; + add_header Cache-Control "no-cache" always; + } + location / { # The cap has to be here and cannot be in @fallback, and that is a fact # about phase order rather than a preference. limit_conn runs in the diff --git a/deploy/nginx/sites-available/org.openipc.dev b/deploy/nginx/sites-available/org.openipc.dev index 4ca4d332..e106b25b 100644 --- a/deploy/nginx/sites-available/org.openipc.dev +++ b/deploy/nginx/sites-available/org.openipc.dev @@ -753,6 +753,18 @@ server { # must not touch what openipc.org serves. See the long note in # sites-available/org.openipc for why the try_files elements are these two # and not `$uri $uri/`. + # The protocol page for AI coding agents (/cameras/report links it), as + # Markdown text: nginx's types have no .md, and an octet-stream answer + # reads as a download to a fetch tool. + location = /agents.md { + root /srv/www/static/dev/current; + default_type "text/markdown; charset=utf-8"; + try_files $uri =404; + add_header Strict-Transport-Security max-age=15768000; + add_header X-Served-By static always; + add_header Cache-Control "no-cache" always; + } + location / { root /srv/www/static/dev/current; try_files $uri $uri/index.html @fallback; diff --git a/frontend/apps/site/public/agents.md b/frontend/apps/site/public/agents.md new file mode 100644 index 00000000..2d78647e --- /dev/null +++ b/frontend/apps/site/public/agents.md @@ -0,0 +1,141 @@ +# openipc.org for AI coding agents + +You are helping someone identify an IP camera or recorder board and, if it is +new to OpenIPC, report it to the board catalogue at +. This page is the whole protocol. Follow +it in order. + +## Rules + +- Work only on a camera the person you are helping owns or is allowed to open. + Never on someone else's device, and never over the internet on a camera you + found there. +- Do not guess or brute-force passwords. Use the console or login the owner + gives you. +- Ask the owner before you read or send the whole flash. It holds their Wi-Fi + keys, passwords and cloud IDs. Send it private unless they explicitly want it + public. +- Nothing you send is public until OpenIPC's maintainers have reviewed it. The + MAC, the chip's die ID and the cloud ID are replaced with hashes in + everything published. + +## 1. Get ipctool's output + +ipctool reads the SoC, sensor, flash layout and firmware off the running board +and prints YAML. Stock firmware has no curl and no TLS, so get it one of these +ways, from a shell on the camera (telnet or UART): + +- **uget** (plain HTTP): paste uget in as text. The scripts are at + `https://openipc.org/uget/uget.arm--linux.sh`; pick by what + `ls /lib/ld-*` prints (`ld-uClibc.so.0`: himix100, hisiv500, hisiv510, + hisiv300; `ld-linux.so.3`: himix200, hisiv600). Then: + + /tmp/uget openipc.org/ipctool > /tmp/ipctool + chmod +x /tmp/ipctool + /tmp/ipctool + + `openipc.org/ipctool-mips32` is the Ingenic build, `openipc.org/ipctool-arm64` + the aarch64 one. uget has no MIPS build; use NFS for Ingenic. + +- **NFS**, if the firmware can mount: + + mkdir /tmp/o && mount -o nolock openipc.org:/ipctool /tmp/o + /tmp/o/ipctool + +- Otherwise TFTP, an SD card or UART: + . + +Keep the output exactly as printed. + +## 2. Ask whether the board is already known + + POST https://openipc.org/api/v1/boards/identify + Content-Type: text/plain + + + +The answer names the SoC as the catalogue does and the boards the output may +be, best first: + +```json +{"facts": {"chip_vendor": "HiSilicon", "chip_model": "3516EV300", "sensor": "Sony IMX335"}, + "identify": {"soc": "hi3516ev300", "known": true, + "matches": [{"model_id": "xiongmai-ipg-50h20pl-s", "model": "IPG-50H20PL-S", "manufacturer": "Xiongmai", + "url": "/cameras/boards?model=xiongmai-ipg-50h20pl-s", "score": 110, + "why": ["board code 50H20PL-S", "SoC hi3516ev300"]}]}} +``` + +`known: true` means a board code in the output is one the catalogue already +has. Show the owner that board's page: it links its OpenIPC firmware and +install instructions. A report is still welcome if the board differs (another +sensor, another flash chip), but it is not needed. + +Nothing is stored by this call. + +## 3. Collect what you can + +Useful, in order of value: + +1. ipctool's output (required). +2. The boot log: the UART console from power-on to the login prompt. +3. The U-Boot environment: `printenv` at the U-Boot prompt, or + `ipctool printenv` from Linux. +4. Photos of both sides of the board, sharp enough to read the chip markings. +5. With the owner's agreement, the whole flash: `ipctool backup /tmp/b.bin` + (it is ipctool's backup format; see step 4). + +## 4. Send the report + + POST https://openipc.org/api/v1/reports + Content-Type: multipart/form-data + +Parts, all optional except the first: + +| part | what | limit | +|---|---|---| +| `yaml` | ipctool's output | 256 KB | +| `backup` (file) | `ipctool backup` output: the YAML, a NUL, then each partition as a little-endian uint32 length and its bytes. If sent, `yaml` may be left out. | 256 MB | +| `consent` | `private` (default: maintainers only) or `public` (published with the report) | | +| `photo` (file, repeatable) | JPEG, PNG or WebP | 20 MB each | +| `boot_log`, `uboot_env`, `note` (file, repeatable) | text | 4 MB each | +| `document` (file) | PDF | 20 MB | +| `channel` | `agent` | | +| `tool` | your name and version, e.g. `claude-code 2.x` | 200 chars | +| `note` | a line of text: where the camera came from, what it is sold as | 4000 chars | + +For example: + + curl -F channel=agent -F tool="" -F yaml=@ipctool.yml \ + -F boot_log=@boot.txt -F photo=@front.jpg -F photo=@back.jpg \ + https://openipc.org/api/v1/reports + +`201` answers with the receipt: + +```json +{"id": "r-7k2m9q4d", "status": "pending", + "receipt_url": "https://openipc.org/cameras/report/?id=r-7k2m9q4d", + "files": [{"kind": "photo", "name": "front.jpg", "bytes": 812345, "sha256": "…"}], + "identify": {"soc": "hi3516ev300", "known": false, "matches": []}} +``` + +Give the owner the `receipt_url`. It shows the report's state until a +maintainer files it under its board, and then links there. + +Refusals are JSON `{"error": "..."}` saying what to change: `400` (not +ipctool's output, a malformed backup), `415` (a file of the wrong type), +`413` (too large), `429` (ten reports a day from one address; retry tomorrow). + +## 5. Check on it + + GET https://openipc.org/api/v1/reports/ + +`status` is `pending`, `published`, `rejected` or `withdrawn`. Once published, +`models` names the board it was filed under. + +## Machine-readable + +- `GET https://openipc.org/api/v1/boards`: the whole catalogue (JSON). +- `GET https://openipc.org/api/v1/boards/search?q=&kind=all`: lines of + U-Boot consoles, boot logs and notes that match. +- `GET https://openipc.org/api/v1/tools`: the ipctool builds openipc.org + serves, with their versions. diff --git a/service/internal/reports/handler.go b/service/internal/reports/handler.go index 574f194e..c6b953ed 100644 --- a/service/internal/reports/handler.go +++ b/service/internal/reports/handler.go @@ -112,6 +112,10 @@ func (a *API) upload(w http.ResponseWriter, r *http.Request) { a.refuse(w, status, err.Error()) return } + if len(in.fields["tool"]) > 200 { + a.refuse(w, http.StatusBadRequest, "tool: at most 200 characters, a name and a version") + return + } channel := orDefault(in.fields["channel"], "ipctool") if channel != "ipctool" && channel != "agent" && channel != "web" { a.refuse(w, http.StatusBadRequest, "channel is ipctool, agent or web") From 4d79127d436013eeec4d8267907920f5bf2c6785 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 29 Sep 2026 12:58:34 +0000 Subject: [PATCH 09/10] Owner reports page: fits a phone, real buttons, the panel's labels translated On a 375px screen the page ran off the right edge. The agent's one-line prompt could not wrap, and the grid columns had no min-w-0, so they grew to it. The prompt now wraps. Copy uget and the receipt lookup use the site's button styles. The panel's "app" and "board" labels come from the dictionary (they read "stock app", in English, on every locale). --- data/locales/boards.en.yml | 2 ++ data/locales/boards.ru.yml | 2 ++ data/locales/boards.zh.yml | 2 ++ frontend/apps/site/src/components/Terminal.astro | 6 +++--- frontend/apps/site/src/components/pages/Report.astro | 6 +++--- frontend/apps/site/src/components/reports/OwnerReports.tsx | 4 ++-- frontend/apps/site/src/components/reports/Receipt.tsx | 2 +- frontend/apps/site/src/components/reports/UgetCopy.tsx | 2 +- frontend/apps/site/src/i18n/boards.en.json | 2 ++ frontend/apps/site/src/i18n/boards.ru.json | 2 ++ frontend/apps/site/src/i18n/boards.zh.json | 2 ++ 11 files changed, 22 insertions(+), 10 deletions(-) diff --git a/data/locales/boards.en.yml b/data/locales/boards.en.yml index dc30bf54..0c6217d9 100644 --- a/data/locales/boards.en.yml +++ b/data/locales/boards.en.yml @@ -77,6 +77,8 @@ en: load_failed: "The report could not be loaded. Try again in a minute." reports_title: "Reports from owners" reports_same_board: "same board as another report" + reports_app: "app" + reports_board: "board" reports_yaml: "ipctool's output" reports_ask: "Have this board?" reports_send: "Send us its report" diff --git a/data/locales/boards.ru.yml b/data/locales/boards.ru.yml index 6c7c8f27..a4e52bd3 100644 --- a/data/locales/boards.ru.yml +++ b/data/locales/boards.ru.yml @@ -77,6 +77,8 @@ ru: load_failed: "Не удалось загрузить отчёт. Попробуйте через минуту." reports_title: "Отчёты владельцев" reports_same_board: "та же плата, что в другом отчёте" + reports_app: "приложение" + reports_board: "плата" reports_yaml: "Вывод ipctool" reports_ask: "Есть такая плата?" reports_send: "Пришлите её отчёт" diff --git a/data/locales/boards.zh.yml b/data/locales/boards.zh.yml index 0cbdea17..d470826b 100644 --- a/data/locales/boards.zh.yml +++ b/data/locales/boards.zh.yml @@ -77,6 +77,8 @@ zh: load_failed: "无法加载报告。请稍后再试。" reports_title: "用户报告" reports_same_board: "与另一份报告是同一块板" + reports_app: "应用" + reports_board: "电路板" reports_yaml: "ipctool 的输出" reports_ask: "有这块板?" reports_send: "发送它的报告" diff --git a/frontend/apps/site/src/components/Terminal.astro b/frontend/apps/site/src/components/Terminal.astro index d3659097..aa4c7e75 100644 --- a/frontend/apps/site/src/components/Terminal.astro +++ b/frontend/apps/site/src/components/Terminal.astro @@ -17,8 +17,8 @@ */ import Icon from './Icon.astro'; -interface Props { code: string; title?: string; id: string } -const { code, title = 'shell', id } = Astro.props; +interface Props { code: string; title?: string; id: string; wrap?: boolean } +const { code, title = 'shell', id, wrap = false } = Astro.props; ---
    @@ -39,7 +39,7 @@ const { code, title = 'shell', id } = Astro.props;
    -
    {code}
    +
    {code}