diff --git a/BSP-INTEGRATION.md b/BSP-INTEGRATION.md index c09d319..d88c0d0 100644 --- a/BSP-INTEGRATION.md +++ b/BSP-INTEGRATION.md @@ -1,4 +1,4 @@ -# BSP 통합 가이드 — Web Configurator v1.11.16 +# BSP 통합 가이드 — Web Configurator v1.12.1 디바이스 이미지(Yocto/OE 등)에 포함하기 위한 **커밋 히스토리 없는 클린 스냅샷**입니다. 런타임 앱·systemd 유닛·샘플·문서와 optional nginx sample 설정만 들어 있고, 개발/내부 자료와 VCS 이력은 없습니다. @@ -7,7 +7,7 @@ ## 1. 런타임 구조 - **Python 3 애플리케이션, stdlib 전용** — pip·virtualenv·외부 패키지 없음. -- 실행 진입점: `python3 /opt/web-configurator/src/server.py` +- 실행 진입점: `python3 /usr/lib/web-configurator/src/server.py` - 리슨 포트: **9090** (유닛의 `Environment=PORT=` 로 변경 가능) - 앱은 기본적으로 `:9090`에서 직접 서비스됩니다. nginx `:80` reverse proxy는 optional이며, `deploy/nginx.conf`는 BSP가 필요할 때 쓰는 sample 설정입니다. - 영속 상태 — **패키지에 없음**(런타임/다른 컴포넌트가 생성): @@ -21,7 +21,7 @@ ## 2. 설치 경로 (`do_install`) | 소스 (이 패키지) | 디바이스 설치 위치 | |---|---| -| `src/` | `/opt/web-configurator/src/` | +| `src/` | `${libdir}/web-configurator/src/` (`${libdir}` = `/usr/lib`) | | `deploy/web-configurator.service` | `${systemd_system_unitdir}/` | | `deploy/dpworld-*.service` | `${systemd_system_unitdir}/` (제품이 사용하는 유닛만) | | `deploy/*.service.d/` | `${systemd_system_unitdir}/.service.d/` | @@ -35,10 +35,12 @@ - Wi-Fi AP 사용 시: `deploy/dpworld-ap-apply.service`, `deploy/dpworld-ap-seed.service`, `deploy/dpworld-hostapd-ap0.service`, `deploy/dpworld-udhcpd-ap0.service`, `deploy/dpworld-ap-apply.sh`를 함께 설치 - 복구 유닛 사용 시: `deploy/dpworld-net-recover.service` 설치 - `*.sh`는 `0755` 실행 권한으로 `/usr/bin/`에 설치 +> 앱 코드 디렉토리 `/usr/lib/web-configurator` 는 `do_install` 이 이미지에 굽는 **read-only program code** 이므로 아래 쓰기 디렉토리 목록에 포함하지 않습니다. + 쓰기 가능 런타임 디렉토리 생성 (recipe `do_install` / tmpfiles.d / 유닛의 `ExecStartPre` 중 택1): ``` -/opt/web-configurator /opt/log/dpworldapp /opt/fw_staging /opt/fw_upload -/opt/config_backups /home/root/db /home/root/network +/opt/log/dpworldapp /opt/fw_staging /opt/fw_upload +/opt/config_backups /home/root/db /home/root/network ``` --- @@ -76,18 +78,18 @@ SYSTEMD_SERVICE:${PN} = "web-configurator.service \ --- -## 5. 샘플 레시피 — `web-configurator_1.11.16.bb` +## 5. 샘플 레시피 — `web-configurator_1.12.1.bb` ```bitbake SUMMARY = "DP World Smart Solutions — Web Configurator" LICENSE = "CLOSED" # 방법 A — 내부 git 서버의 태그 릴리스를 fetch (배포물이 repo 루트에 평탄 배치됨): SRC_URI = "git:////NewWebConfigurator.git;branch=;protocol=ssh" -SRCREV = "" +SRCREV = "" S = "${WORKDIR}/git" # 방법 B — git archive 로 만든 스냅샷 tarball 사용: -# (git -C archive v1.11.16 | gzip > web-configurator-v1.11.16.tar.gz) -# SRC_URI = "file://web-configurator-v1.11.16.tar.gz" +# (git -C archive v1.12.1 | gzip > web-configurator-v1.12.1.tar.gz) +# SRC_URI = "file://web-configurator-v1.12.1.tar.gz" # S = "${WORKDIR}" inherit systemd @@ -104,9 +106,9 @@ SYSTEMD_SERVICE:${PN} = "web-configurator.service" SYSTEMD_AUTO_ENABLE = "enable" do_install() { - # 애플리케이션 - install -d ${D}/opt/web-configurator - cp -r ${S}/src ${D}/opt/web-configurator/ + # 애플리케이션 (read-only program code → ${libdir} = /usr/lib) + install -d ${D}${libdir}/web-configurator + cp -r ${S}/src ${D}${libdir}/web-configurator/ # systemd 유닛 install -d ${D}${systemd_system_unitdir} @@ -150,7 +152,7 @@ do_install() { ${D}/opt/config_backups ${D}/home/root/db ${D}/home/root/network } -FILES:${PN} += "/opt/web-configurator /opt/log ${systemd_system_unitdir} \ +FILES:${PN} += "${libdir}/web-configurator /opt/log ${systemd_system_unitdir} \ /opt/fw_staging /opt/fw_upload /opt/config_backups \ /home/root/db /home/root/network ${bindir}/dpworld-*.sh \ ${sysconfdir}/nginx" diff --git a/CHANGELOG.md b/CHANGELOG.md index fff824b..927ff6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ --- +## [v1.12.1] — 2026-06-29 +### Added +- **텔레메트리 Uplink (Ethernet 전환)**: dpworldapp 클라우드 텔레메트리(프로토콜·업데이트·RTCM) 송신을 웹 UI 에서 Wi-Fi↔Ethernet 전환. 호스트 라우트(/32)만 사용 — dpworldapp·OS 설정 무접촉. 워치독 자가복원 + 관리 인터페이스 보호. +- **라우팅 투명성**: 서버별 실제 송신 인터페이스 표시 + 인터페이스 선택의 영향(로컬 LAN 가림 등) 적용 전 경고(읽기 전용). +### Fixed +- **전체 소스 리뷰 41건 반영**(정직성·안전성·구조): 점검 전 상태의 거짓 정상표시 제거, 설정 로드 실패 시 빈 값 저장 차단, 로그 자동정리 마지막 아카이브 과삭제 방지, 중복 경로/보안 로직 통합 등. **dpworldapp 공유 DB(device/protocol 설정)·계약·타입은 무변경.** +### Note +- 설치 경로(`/usr/lib/web-configurator`)·기본 포트(`:9090`)는 v1.12.0 그대로 유지. + +## [v1.12.0] — 2026-06-25 +### Changed +- **앱 코드 설치 경로 이전**: `/opt/web-configurator` → `/usr/lib/web-configurator` (협력사 BSP + 베이킹 정합 — FHS상 `/usr/lib` 가 read-only program code 자리). 쓰기 런타임 데이터는 그대로 유지: + 로그 `/opt/log/dpworldapp`, 펌웨어 `/opt/fw_staging`·`/opt/fw_upload`, 설정 백업 `/opt/config_backups`. +- `web-configurator.service`: `ExecStart`·`WorkingDirectory` 를 새 경로로 변경. 코드 디렉토리는 + 서비스가 직접 쓰지 않으므로 `ReadWritePaths` 에서 제거(`ProtectSystem=strict` 하 read-only 로 충분). +- `BSP-INTEGRATION.md` 레시피: 코드 설치를 `${libdir}/web-configurator`(=`/usr/lib`) 로 변경, + 쓰기 디렉토리 목록에서 코드 디렉토리 제외. `scripts/deploy.ps1` `$AppDir` 동일 이전. +- **기본 포트 통일 8080→9090**: `server.py` 코드 기본 포트를 9090 으로 변경(systemd 유닛값과 동일). + 설정기는 PC 개발·디바이스 모두 `:9090` 단일 포트. 디바이스의 `:8080` 은 별개의 레거시 Java + app-runner 임을 문서에서 명확화(README/DEPLOY/network-apply-engine-guide). 디바이스 동작은 + 무변경(유닛이 이미 `PORT=9090` 지정) — bare/PC 실행 시 기본값만 9090 으로 바뀜. + +> 앱 로직·UI·디바이스 계약은 무변경. 설치 경로(배포 계약) 이전 + 포트 정돈. 이전 버전: v1.11.16. + +--- + ## [v1.11.16] — 2026-06-23 ### Changed - `web-configurator.service` 의 `MemoryMax` 를 **48M → 128M** 로 상향. support-bundle(진단 zip, 메모리 빌드) diff --git a/DELIVERABLE-MANIFEST.txt b/DELIVERABLE-MANIFEST.txt index d3f42e3..d42ebeb 100644 --- a/DELIVERABLE-MANIFEST.txt +++ b/DELIVERABLE-MANIFEST.txt @@ -1,7 +1,7 @@ DP World Smart Solutions — Web Configurator -파트너 전달물 — v1.11.16 +파트너 전달물 — v1.12.1 BSP 이미지 포함용 클린 스냅샷. 커밋 히스토리 없음, 내부 전용 자료 없음. -일자: 2026-06-23 +일자: 2026-06-29 ================================================================================ 이 패키지는 무엇인가 @@ -24,8 +24,12 @@ src/ 애플리케이션 (Python 3, stdlib 전용) enum_normalizer.py 입력 별칭 정규화 migrations.py 부팅 시 1회 DB 마이그레이션 (schema_meta 게이트, 멱등) db_manager.py SQLite(board_config) 접근 - network/ firmware/ network-apply / 펌웨어 OTA 서브시스템 + network/ network-apply 서브시스템 + uplink.py [v1.12.1 신규] 텔레메트리 Uplink — 호스트 라우트(/32) 관리 + 워치독 + firmware/ 펌웨어 OTA 서브시스템 static/ 웹 UI (HTML/CSS/JS) + js/pages/uplink.js [v1.12.1 신규] 텔레메트리 Uplink UI (인터페이스 전환 + 라우팅 투명성) + [v1.12.1 전체 소스 리뷰 41건 안정화: 정직성·안전성·구조 개선. dpworldapp 공유 DB·계약·타입 무변경] deploy/ 디바이스 통합 산출물 web-configurator.service 메인 systemd 유닛 (python3 src/server.py, :9090) nginx.conf optional reverse proxy sample (:80 → 앱) @@ -40,7 +44,7 @@ README.md CHANGELOG.md VERSION RELEASE-NOTES.md BSP-INTEGRATION.md DELIVERA ================================================================================ 설치 위치 (디바이스) — 샘플 recipe는 BSP-INTEGRATION.md 참조 ================================================================================ -src/ → /opt/web-configurator/src/ +src/ → /usr/lib/web-configurator/src/ (${libdir}=/usr/lib) deploy/web-configurator.service → systemd 유닛 디렉토리 deploy/dpworld-*.service + *.d/ → systemd 유닛 디렉토리 (제품이 사용하는 것만) deploy/dpworld-*.sh / *.conf → 각 유닛 ExecStart 가 가리키는 경로 @@ -54,8 +58,9 @@ enable: web-configurator.service (+ 필요한 dpworld-* 유닛) 리슨 : PORT 9090 (유닛 env로 변경 가능) DB : /home/root/db/dynamic_data.db (env DB_PATH) 로그 : /opt/log/dpworldapp (env LOG_DIR) -쓰기 경로 : /opt/web-configurator /opt/log/dpworldapp /opt/fw_staging - /opt/fw_upload /opt/config_backups /home/root/db /home/root/network +쓰기 경로 : /opt/log/dpworldapp /opt/fw_staging /opt/fw_upload + /opt/config_backups /home/root/db /home/root/network + (앱 코드 /usr/lib/web-configurator 는 read-only — 쓰기 경로 아님) 메모리 상한 : MemoryMax=128M (유닛에 설정됨; v1.11.16에서 48M→128M 상향) nginx optional: deploy/nginx.conf 는 :80 reverse proxy가 필요한 BSP용 sample입니다. diff --git a/README.md b/README.md index 2127ad9..7604eae 100644 --- a/README.md +++ b/README.md @@ -30,18 +30,18 @@ SQLite 데이터베이스(`board_config` 테이블)에 저장됩니다. python src/server.py ``` -서버는 기본적으로 `0.0.0.0:8080`에 바인딩됩니다 (로컬 개발용). 환경 변수로 재정의할 수 있습니다: +서버는 기본적으로 `0.0.0.0:9090`에 바인딩됩니다. 환경 변수로 재정의할 수 있습니다: | 변수 | 기본값 | 설명 | |---|---|---| | `HOST` | `0.0.0.0` | 바인딩 주소 | -| `PORT` | `8080` | 수신 포트 | +| `PORT` | `9090` | 수신 포트 | | `DB_PATH` | `/home/root/db/dynamic_data.db` | SQLite 데이터베이스 경로 | | `LOG_DIR` | `/opt/log/dpworldapp` | 로그 파일 디렉터리 | -> **포트 안내:** `8080`은 로컬 개발 환경에서만 사용하는 기본값입니다. 디바이스에서는 -> systemd 유닛(`deploy/web-configurator.service`)이 `PORT=9090`으로 설정하므로, -> 배포된 configurator는 **9090** 포트에서 수신합니다 — [docs/DEPLOY.md](docs/DEPLOY.md) §7 참조. +> **포트 안내:** 설정기는 **PC·디바이스 어디서든 `9090`** 입니다 (디바이스는 systemd 유닛 +> `deploy/web-configurator.service` 이 `PORT=9090` 을 지정 — 코드 기본값과 동일). 디바이스의 +> `:8080` 은 **별개의 레거시 Java app-runner**(이 앱 아님)이며 무관합니다 — [docs/DEPLOY.md](docs/DEPLOY.md) §7 참조. 예시: diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 03ed95c..0bf3c0c 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,36 +1,37 @@ -# 릴리스 노트 — Web Configurator **v1.11.16** +# 릴리스 노트 — Web Configurator **v1.12.1** -- **배포일**: 2026-06-23 +- **배포일**: 2026-06-29 - **대상**: 디바이스 BSP 이미지 포함용 (DP World Smart Solutions · IoT 디바이스) -- **패키지**: 내부 git 태그 `v1.11.16` (커밋 히스토리가 없는 **클린 스냅샷**) -- **이전 버전**: v1.11.15 +- **패키지**: 내부 git 태그 `v1.12.1` (커밋 히스토리가 없는 **클린 스냅샷**) +- **이전 버전**: v1.12.0 --- ## 1. 한눈에 보기 -이번 릴리스는 **배포 패키지 정비 릴리스**입니다. 앱 기능·디바이스 계약·UI는 v1.11.15와 동일하며, -다음 두 가지를 정비했습니다. - -1. **MemoryMax 상향** (`48M → 128M`) — 운영 중 OOM-kill 위험 제거. -2. **BSP 통합 가이드 정비** — 네트워크-적용 하드닝 스크립트의 권장 통합 방식을 - **"펌웨어 원본 스크립트 직접 교체(BSP 베이킹)"** 를 1차 방식으로 재구성. - -- Python 3 **표준 라이브러리(stdlib)만** 사용 — 외부 pip 패키지 없음 -- systemd 유닛 포함, nginx 설정은 optional sample → **이미지에 바로 포함 가능** -- 기존 디바이스 DB는 **부팅 시 자동 변환**(1회, 멱등) — 수동 작업 불필요 +이번 릴리스는 **텔레메트리 Uplink(Ethernet 전환) 기능 추가 + 전체 안정화** 릴리스입니다. + +- **신규 — 텔레메트리 Uplink**: dpworldapp 의 클라우드 텔레메트리(프로토콜·업데이트·RTCM 서버) 송신 경로를 + 웹 UI 에서 **Wi-Fi ↔ Ethernet 으로 전환**합니다. OS 라우팅(호스트 라우트)만 사용하며, dpworldapp 과 + OS 네트워크 설정 파일은 **건드리지 않습니다**. +- **신규 — 라우팅 투명성**: 서버별 실제 송신 인터페이스를 표시하고, 인터페이스 선택이 로컬 LAN 호스트를 + 가리는 등 충돌 가능성이 있으면 **적용 전에 경고**합니다(읽기 전용 — 포워딩/NAT 를 열지 않음). +- **안정화 — 전체 소스 리뷰 41건 반영**: 정직성(점검되지 않은 상태를 정상으로 표시하던 경로 제거), + 안전성(일부 실패 상황에서 설정이 빈 값으로 덮어써지던 경로 차단, 로그 아카이브 과삭제 방지), + 구조(중복 로직 통합) 개선. **dpworldapp 공유 DB(device/protocol 설정)·원자적 쓰기 계약은 무변경.** +- v1.12.0 의 설치 경로(`/usr/lib/web-configurator`)·기본 포트(`:9090`)는 **그대로 유지**됩니다. +- Python 3 **표준 라이브러리(stdlib)만** 사용 — 외부 pip 패키지 없음. --- -## 2. 주요 변경 (v1.11.15 → v1.11.16) +## 2. 주요 변경 (v1.12.0 → v1.12.1) | 항목 | 변경 | |---|---| -| **MemoryMax** | `web-configurator.service` 의 `MemoryMax` 를 **48M → 128M** 로 상향. 48M는 support-bundle(진단 zip, 메모리 빌드)과 UI 동시 사용 시 실측 피크 ~40-50MB로 OOM-kill 위험이 있었음. 128M = 실피크 ~2.5x 여유 + 누수 방지 캡 유지 (장비 RAM 3.4GB 대비 3.7%). | -| **BSP 통합 방식 1차 = 직접 교체** | 네트워크-적용 하드닝본을 **`/usr/bin/dpworld-network-apply.sh` 원본 이름으로 교체 설치**하는 것을 BSP 베이킹의 기본 방식으로 명시. `.service.d` override 드롭인은 **라이브 디바이스 후적용(over-the-top) fallback** 으로 강등. (자세히 `BSP-INTEGRATION.md` §7 + `docs/firmware-boot-hardening.md`) | -| 패키지 구성 정리 | 배포물을 repo 루트(flat)로 정리, 내부 전용 참조/링크 정돈. 런타임 코드 변경 없음. | +| **텔레메트리 Uplink (신규)** | dpworldapp 클라우드 텔레메트리 송신을 웹 UI 에서 Wi-Fi↔Ethernet 전환. 선택한 인터페이스의 게이트웨이를 통해 대상 서버 IP 에 대한 **호스트 라우트(/32)** 를 설치하는 방식 — relay·dpworldapp·OS 네트워크 파일 무변경. 적용은 비동기로 즉시 반환하고, 워치독이 라우트 유실 시 **자가복원**합니다. 관리 인터페이스가 끊기지 않도록 안전장치를 포함합니다. | +| **라우팅 투명성 + 사전 경고 (신규)** | 서버별 라우팅 상태와 실제 송신 인터페이스를 표시하고, 인터페이스 선택의 영향(로컬 LAN 호스트 가림 등)을 **적용 전에 경고**. 읽기 전용 — 포워딩/NAT 를 열지 않습니다. | +| **전체 소스 리뷰 41건 반영 (안정화)** | 정직성·안전성·구조 개선. 대표적으로: 점검 전 상태를 정상으로 표시하던 대시보드 경로 수정, 설정 로드 실패 시 빈 값 저장으로 인한 설정 손실 차단, 로그 자동정리의 마지막 아카이브 과삭제 방지, 중복된 경로/보안 로직 통합. **dpworldapp 공유 DB·계약·타입은 무변경.** | -> 앱 코드 자체는 **v1.11.15와 동일**합니다 (UI/기능 무변경). 본 릴리스는 배포 단위의 -> 메모리 정책 + 통합 가이드 정비입니다. +> v1.12.0 에서 이전한 설치 경로(`/usr/lib`)·기본 포트(`:9090`)는 본 릴리스에서도 유지됩니다. --- @@ -58,7 +59,10 @@ --- ## 4. 호환성 · 주의사항 -- **디바이스 적용 시점**: dpworldapp 재시작 시 새 계약값으로 config가 재적용됩니다. +- **텔레메트리 Uplink**: 인터페이스 전환은 **호스트 라우트(/32)** 만 설치/제거하며 dpworldapp·OS 설정 + 파일을 변경하지 않습니다. 전환을 즉시 반영하려면 dpworldapp 재시작 또는 디바이스 재기동이 필요할 수 + 있습니다(기존 TCP 연결은 전환 시점에 자동 이전되지 않음). +- **디바이스 적용 시점**: dpworldapp 재시작 시 새 계약값으로 config 가 재적용됩니다. - **nginx optional**: `deploy/nginx.conf`는 BSP에서 `:80` reverse proxy를 제공할 때 쓰는 sample입니다. `scripts/deploy.ps1`는 nginx를 설치/수정하지 않으며, 디바이스가 앱을 `:9090`으로 직접 접근하면 없어도 됩니다. sample을 쓸 경우 `proxy_pass`를 앱 포트 `:9090`으로 맞추세요. - **런타임 의존성**: python3 stdlib만 필요 (RDEPENDS 목록은 `BSP-INTEGRATION.md` 참조). 외부 패키지 없음. - **dpworldapp**(텔레메트리 바이너리)는 firmware-native. @@ -66,9 +70,9 @@ --- ## 5. 배포 방법 (BSP) -1. **패키지 입수**: 내부 git 태그 `v1.11.16` (또는 동봉 스냅샷) +1. **패키지 입수**: 내부 git 태그 `v1.12.1` (또는 동봉 스냅샷) 2. **레시피 작성**: `BSP-INTEGRATION.md`의 샘플 bitbake recipe + 설치 경로표 사용 - - `src/` → `/opt/web-configurator/src/` + - `src/` → `${libdir}/web-configurator/src/` (`${libdir}` = `/usr/lib`) - `deploy/*.service` → systemd 유닛 디렉토리 - `deploy/dpworld-network-apply-hardened.sh` → **`/usr/bin/dpworld-network-apply.sh`** (펌웨어 원본 교체, §3 ①) - `deploy/nginx.conf` → optional nginx sample site config @@ -78,15 +82,16 @@ --- ## 6. 품질/검증 -- v1.11.16 변경은 **앱 로직 무변경** — systemd 서비스 메모리 캡(MemoryMax) + 문서/주석/버전 문자열뿐입니다. - 기능 동등 기준선 **v1.11.15**는 내부 테스트 스위트(Python + 프런트 `.mjs`)로 검증되었습니다. +- 본 릴리스는 내부 테스트 스위트(Python + 프런트 `.mjs`)로 검증되었으며, 텔레메트리 Uplink 기능과 + 41 건의 리뷰 수정이 모두 회귀 테스트를 통과했습니다. +- 텔레메트리 Uplink·라우팅 투명성은 운영 디바이스에서 동작 확인되었습니다(dpworldapp·공유 설정 무접촉). - 테스트 스위트는 내부 저장소에서 관리되며 **본 배포물에는 포함되지 않습니다**. - 패키지는 **커밋 히스토리 없음**(`git archive` 스냅샷) + 내부 분석/개발 자료 제외 (`DELIVERABLE-MANIFEST.txt` 참조). --- ## 7. 롤백 -- BSP recipe의 버전 핀(태그/SRCREV)을 **v1.11.15**로 되돌리면 이전 버전 배포. +- BSP recipe의 버전 핀(태그/SRCREV)을 **v1.12.0**으로 되돌리면 이전 버전 배포. - 마이그레이션은 **멱등 + 변경 전 자동 백업**(config_safety)으로 보호됩니다. --- @@ -94,7 +99,9 @@ ## 버전 이력 (요약) | 버전 | 일자 | 요약 | |---|---|---| -| **v1.11.16** | 2026-06-23 | MemoryMax 48M→128M + BSP 베이킹(직접 교체) 가이드 1차 정비 (앱 기능 무변경) | +| **v1.12.1** | 2026-06-29 | 텔레메트리 Uplink(Ethernet 전환) + 라우팅 투명성 추가 + 전체 소스 리뷰 41건 안정화 (설치 경로·포트는 v1.12.0 유지) | +| v1.12.0 | 2026-06-25 | 앱 코드 설치 경로 `/opt/web-configurator` → `/usr/lib/web-configurator` 이전 + 기본 포트 8080→9090 (앱 기능 무변경) | +| v1.11.16 | 2026-06-23 | MemoryMax 48M→128M + BSP 베이킹(직접 교체) 가이드 1차 정비 | | v1.11.15 | 2026-06-23 | 사이드바 System 그룹 monitor 아이콘 정리 | | v1.11.14 | 2026-06-23 | 사이드바 부모 그룹(System=Dashboard+Log / Firmware) 재편 | | v1.11.11 | 2026-06-23 | config 쓰기 경로를 디바이스 config-reader 계약에 정렬 + 자동 마이그레이션 | diff --git a/VERSION b/VERSION index 8f253a3..51b86ba 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.11.16 +v1.12.1 diff --git a/deploy/nginx.conf b/deploy/nginx.conf deleted file mode 100644 index f766403..0000000 --- a/deploy/nginx.conf +++ /dev/null @@ -1,25 +0,0 @@ -server { - listen 80; - server_name _; - - # Static files - location / { - root /opt/web-configurator/static; - try_files $uri $uri/ /index.html; - expires 1h; - add_header Cache-Control "public, must-revalidate"; - } - - location /configuration/ { - proxy_pass http://127.0.0.1:8080/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 30s; - } - - location = /configuration { - return 301 /configuration/; - } -} diff --git a/deploy/web-configurator.service b/deploy/web-configurator.service index 893c7af..7c6a099 100644 --- a/deploy/web-configurator.service +++ b/deploy/web-configurator.service @@ -4,6 +4,10 @@ # v1.11.16: MemoryMax 48M→128M. 48M was tight — support-bundle(io.BytesIO)+UI 동시 # 사용 시 실측 피크 ~40-50MB로 OOM-kill 위험. 128M = 실피크 ~2.5x 여유 + 누수 방지 # 캡 유지(장비 RAM 3.4GB 대비 3.7%). 정적자산 최대 93KB·업로드는 디스크 스트리밍. +# v1.12.0: 앱 코드 디렉토리 /opt/web-configurator → /usr/lib/web-configurator (협력사 BSP +# 베이킹 정합 — FHS상 /usr/lib 가 read-only program code 자리). 쓰기 런타임(/opt/log, +# /opt/fw_*, /opt/config_backups)은 /opt 유지. 코드 디렉토리는 서비스가 직접 쓰지 않으므로 +# ReadWritePaths 에서 제거(ProtectSystem=strict 하 read-only 로 충분). [Unit] Description=IoT Web Configurator # v1.6.0 C3: wpa_supplicant@wlan0 이후 기동 — watchdog 첫 틱에 /var/run/wpa_supplicant @@ -15,8 +19,8 @@ StartLimitBurst=5 [Service] Type=simple -ExecStart=/usr/bin/python3 /opt/web-configurator/src/server.py -WorkingDirectory=/opt/web-configurator/src +ExecStart=/usr/bin/python3 /usr/lib/web-configurator/src/server.py +WorkingDirectory=/usr/lib/web-configurator/src Restart=always RestartSec=5 Environment=DB_PATH=/home/root/db/dynamic_data.db @@ -37,7 +41,7 @@ ProtectHome=read-only # /tmp symlink 공격면 일부 복원 — 폐쇄 LAN 단일 운영자 장비라 수용 (CHANGELOG 기록). PrivateTmp=no PrivateDevices=yes -ReadWritePaths=/home/root/db /opt/log/dpworldapp /opt/web-configurator /opt/fw_staging /opt/fw_upload -/opt/config_backups -/home/root/network /tmp +ReadWritePaths=/home/root/db /opt/log/dpworldapp /opt/fw_staging /opt/fw_upload -/opt/config_backups -/home/root/network /tmp # /var/run 은 /run 의 symlink — namespace 는 실경로 기준이므로 둘 다 지정 (.56 실측) ReadWritePaths=-/var/run/wpa_supplicant -/run/wpa_supplicant ProtectKernelTunables=yes diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 924da99..9ca90ad 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -100,7 +100,7 @@ NEW Web Configurator는 여러 현장의 IoT 디바이스에 배포되는 Python 배포 성공 후, 디바이스에서 실행 중인 내용을 확인합니다: ```bash -ssh root@ cat /opt/web-configurator/DEPLOYED_VERSION +ssh root@ cat /usr/lib/web-configurator/DEPLOYED_VERSION ``` 이 파일은 매 배포 시 덮어쓰이며, 다음 내용을 포함합니다: @@ -116,7 +116,7 @@ deployed_by=@ 배포 이력을 확인하려면: ```bash -ssh root@ cat /opt/web-configurator/deploy-history.log +ssh root@ cat /usr/lib/web-configurator/deploy-history.log ``` 각 줄의 형식: ` `. @@ -139,11 +139,11 @@ ssh root@ cat /opt/web-configurator/deploy-history.log | 항목 | 값 | |---|---| -| 앱 디렉터리 | `/opt/web-configurator/` | +| 앱 디렉터리 | `/usr/lib/web-configurator/` | | systemd 서비스 | `web-configurator.service`가 `src/server.py` 실행 | | Python 설정 서버 포트 | **9090** | | Nginx (리버스 프록시) | 포트 **80** | -| Java app-runner | 포트 **8080** | +| Java app-runner (레거시·별개 프로그램) | 포트 **8080** — 이 앱(설정기)과 무관 | | 디바이스 — 프로덕션(메인) | `192.168.55.56` (유선 `eth1`) | | 디바이스 — 개발/검증(보조) | `192.168.55.54` | | Python 런타임 | Python 3.10 | @@ -153,10 +153,10 @@ ssh root@ cat /opt/web-configurator/deploy-history.log | 경로 | 용도 | |---|---| -| `/opt/web-configurator/src/` | 실행 중인 애플리케이션 소스 | -| `/opt/web-configurator/backups/` | 디바이스 내 백업 스냅샷(최근 3개) | -| `/opt/web-configurator/DEPLOYED_VERSION` | 현재 버전 기록 파일 | -| `/opt/web-configurator/deploy-history.log` | 배포별 감사 로그 | +| `/usr/lib/web-configurator/src/` | 실행 중인 애플리케이션 소스 | +| `/usr/lib/web-configurator/backups/` | 디바이스 내 백업 스냅샷(최근 3개) | +| `/usr/lib/web-configurator/DEPLOYED_VERSION` | 현재 버전 기록 파일 | +| `/usr/lib/web-configurator/deploy-history.log` | 배포별 감사 로그 | | `/home/root/db/dynamic_data.db` | SQLite 설정 데이터베이스(`board_config` 테이블) | | `/opt/log/dpworldapp/` | 애플리케이션 로그 디렉터리 | diff --git a/docs/architecture.md b/docs/architecture.md index d94852c..993c701 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -361,7 +361,7 @@ NEW_Web_Configurator/ ├── CHANGELOG.md README.md .gitignore ``` -> 디바이스 배포 위치: `/opt/web-configurator/src/` (systemd가 구동, port 9090). +> 디바이스 배포 위치: `/usr/lib/web-configurator/src/` (systemd가 구동, port 9090). > `deploy/`의 systemd 유닛·셸 스크립트는 rootfs `/lib/systemd` 등에 설치되며 **flash 마다 wipe** > 된다(§10 caveat, [`wifi-ap-guide.md`](wifi-ap-guide.md) 참조). diff --git a/docs/deploy-integration-guide.html b/docs/deploy-integration-guide.html new file mode 100644 index 0000000..dd19879 --- /dev/null +++ b/docs/deploy-integration-guide.html @@ -0,0 +1,168 @@ + + + + + +Web Configurator — deploy/ 통합 가이드 (v1.12.1) + + + +
+ +

Web Configurator — deploy/ 통합 가이드 (협력사 전달용)

+
+ 목적: deploy/ 폴더의 구성요소 중 꼭 필요한 것과 그 기능을 정리하고, 기존 dpworldapp/펌웨어와의 "충돌" 오해를 해소합니다.
+ 대상 버전: v1.12.1 · 검증: deploy/ 통합 의미론은 v1.11.15 기준 소스 코드 + 실디바이스(192.168.55.54, 펌웨어 원본 상태) 대조 완료. v1.12.0 설치 경로 이전(/usr/lib)과 v1.12.1 nginx.conf 제거를 반영했습니다. +
+ +

0. 한눈에 — 핵심 3가지

+
    +
  1. 웹 설정기 앱 자체는 네트워크/AP 파일 없이도 단독으로 동작합니다 (:9090). 앱은 src/(파이썬) + web-configurator.service 두 가지만 있으면 기동·설정 저장이 됩니다.
  2. +
  3. "충돌"의 실체는 단 3개 파일(네트워크 드롭인)이 펌웨어가 소유한 유닛을 덮어쓰는 것뿐입니다. 그 외 모든 파일은 새로 추가(additive)되는 것이라 기존 자원을 전혀 건드리지 않습니다 — 크래시·에러를 내지 않습니다.
  4. +
  5. 따라서 원하는 기능만 골라 설치하면 충돌 없이 통합됩니다. (§3에 3가지 통합 방식)
  6. +
+
+ 경로 표준 (v1.12.0~, 협력사 협의 반영): 앱 코드는 /usr/lib/web-configurator 에 설치합니다 — FHS상 /usr/lib 가 read-only program code 자리이며, BSP가 이미지에 굽는 read-only rootfs에 적합합니다. 쓰기 런타임 데이터(로그·펌웨어 staging/upload·설정 백업)는 /usr/lib(read-only)가 아니라 쓰기 가능한 영속 파티션(/opt, /home/root) 에 둡니다(§4).

+ 신규 텔레메트리 Uplink (v1.12.1) 는 런타임에 OS 호스트 라우트(/32)만 조작하므로 deploy/ 추가 구성요소가 필요 없습니다 — 새 유닛/스크립트 없이 기존 web-configurator.service(root 권한) 만으로 동작합니다. +
+ +

1. 기능 그룹과 필요한 파일

+

deploy/의 구성요소는 3개 기능 그룹으로 나뉩니다. 그룹 단위로 켜고 끌 수 있습니다.

+ +

A. 웹 설정기 (필수 — 항상 설치)

+ + + + + + +
파일기능설치 위치
(앱 본체 src/)파이썬 stdlib HTTP 서버. 웹 UI + 설정 API. :9090 리슨/usr/lib/web-configurator/src (표준 — read-only rootfs 가능; 쓰기 데이터는 §4)
web-configurator.service위 앱을 부팅 자동기동 + 크래시 시 재시작하는 systemd 유닛${systemd_system_unitdir}
+

이 그룹만으로 웹 설정기가 동작합니다(설정은 DB /home/root/db에 저장). dpworldapp과 충돌하지 않습니다.

+ +

B. 네트워크 즉시(라이브) 적용 — 선택

+
+ 왜 필요한가: dpworldapp재기동 시에만 네트워크/설정을 OS에 반영합니다. 이 그룹은 웹 UI에서 IP·Wi-Fi(SSID/비밀번호)를 바꾸면 재부팅·dpworldapp 재기동 없이 즉시 반영하기 위한 보완 구성입니다.

+ 동작 메커니즘: 웹이 렌더 파일을 /home/root/network/에 기록 → 적용 스크립트가 그것을 /run/systemd/network/로 동기화 + networkctl/wpa_cli 재구성. (※ networkctl/home/root/network를 직접 읽지 않으므로 이 동기화 스크립트가 있어야 라이브 적용이 됩니다.) +
+ + + + + + +
파일기능설치 위치
dpworld-network-apply-hardened.sh네트워크 적용 스크립트. 펌웨어 원본의 안전 버전 — Wi-Fi country 변경 시 modprobe -r wlan(QCA6490 워치독 리부팅 루프 유발)을 제거하고 country를 재부팅 시 적용(reboot-deferred)으로 처리/usr/bin/ (0755)
dpworld-net-recover.service (옵션)wlan 모듈이 완전히 내려갔을 때 복구(modprobe + wpa 재시작). 웹/워치독이 on-demand로 호출${systemd_system_unitdir}
+
위 스크립트를 어떻게 펌웨어 적용 경로에 연결할지(드롭인 vs 전용 유닛 vs 미사용)는 §3에서 선택합니다. 드롭인 3종은 §3 옵션 ③에서만 사용합니다.
+ +

C. Wi-Fi AP 모드 — 선택

+
기능: 장치를 Wi-Fi AP로 띄워 작업자가 휴대폰/노트북으로 직접 접속(현장 provisioning). wlan0(STA)은 건드리지 않고 ap0 가상 인터페이스를 추가해 사용. hostapd + udhcpd + ap0 전용 방화벽(INPUT 전체 개방·WPA2 PSK가 접근 게이트 / FORWARD 차단 = AP 클라이언트의 내부망·PLC(eth1) 경유 차단).
+ + + + + + + + + +
파일기능설치 위치
dpworld-ap-apply.shAP 기동/해제 스크립트(ap0 생성·IP·방화벽·hostapd/udhcpd 시작)/usr/bin/ (0755)
dpworld-ap-apply.service웹이 on-demand로 호출하는 AP 적용 유닛 ([Install] 없음 = 부팅 자동기동 안 함)${systemd_system_unitdir}
dpworld-hostapd-ap0.serviceap0에서 hostapd 실행 ([Install] 없음, ap-apply.sh가 기동)${systemd_system_unitdir}
dpworld-udhcpd-ap0.serviceap0에서 DHCP 서버 실행 ([Install] 없음, ap-apply.sh가 기동)${systemd_system_unitdir}
dpworld-ap-seed.service (옵션)재부팅 후 AP를 자동 재기동(복구). 없으면 재부팅 후 AP 수동 재활성 필요${systemd_system_unitdir}
+
AP 그룹은 전적으로 신규 구성입니다. dpworldapp/펌웨어 자원을 건드리지 않으며, ap0 전용이라 STA·eth·dpworldapp과 충돌하지 않습니다.
+ +

2. 제외 / 참고

+ + + + + + +
파일사유
deploy/nginx.confv1.12.1에서 패키지에서 제거됨. 앱은 :9090에서 직접 서비스되므로 reverse proxy 없이 동작합니다. 협력사가 자체 nginx로 :80 프런트할 경우, 협력사 설정에서 proxy_pass http://127.0.0.1:9090/만 잡으면 됩니다(앱 포트는 :9090 — 레거시 :8080 Java app-runner 아님).
scripts/board_trace*.sh(deploy/ 밖이지만 참고) board_config를 폴링해 /tmp에 기록하는 개발 진단용 스크립트 — 운영 배포물에서 제거 권장
+ +

3. "충돌" 해소 — 네트워크 적용 통합 3가지 방식

+

"충돌"은 §1-B의 라이브 적용을 펌웨어 유닛을 빌려서 수행하기 때문에 발생합니다. 웹은 적용을 dpworld-network-apply.service(펌웨어 소유 유닛) 이름으로 호출하므로, 그 유닛이 우리 스크립트를 실행하게 하려면 유닛을 건드려야 합니다. 아래 3가지 중 선택하세요.

+ + + + + + + +
방식설치 파일펌웨어 유닛 접촉라이브 적용비고
① 저장 전용네트워크 파일 0개없음(무접촉)✗ (dpworldapp 재기동 시 반영)웹은 설정 편집·저장만. 충돌 0. 가장 단순
② 전용 유닛 (권장)dpworld-network-apply-hardened.sh + 신규 웹 전용 유닛없음(무접촉)웹이 펌웨어 유닛 대신 자체 유닛으로 적용. 웹 소규모 코드 변경 필요(적용 유닛명을 NET_APPLY_SERVICE env로 분리 — 현재는 dpworld-network-apply.service로 하드코딩)
③ 드롭인 override (현재 패키지)dpworld-network-apply-hardened.sh + 드롭인 3종있음(override)펌웨어 유닛의 ExecStart/의존성을 우리 것으로 교체. 이것이 협력사가 본 "충돌"
+

드롭인 3종(③에서만 사용):

+
    +
  • dpworld-network-apply.service.d/20-hardened.conf — 펌웨어 dpworld-network-apply.serviceExecStart를 hardened.sh로 교체
  • +
  • dpworld-network-seed.service.d/20-hardened.conf — 펌웨어 dpworld-network-seed.service(부팅 seed)를 hardened.sh --boot로 교체
  • +
  • dpworld-network-apply-ondemand.conf — 위 유닛의 Requires=(boot-only seed 의존)를 비워 on-demand 기동 허용
  • +
+
권장: 펌웨어 베이킹(BSP)에서는 하드닝본을 /usr/bin/dpworld-network-apply.sh 원본 이름으로 직접 교체하는 방식이 1차입니다(드롭인 미설치 — BSP-INTEGRATION.md §7 ①). 펌웨어 유닛을 못 건드리는 라이브 디바이스에서만 드롭인 override(③)를 fallback으로 씁니다. 라이브 적용이 불필요하면 ① 저장 전용. (③/직접교체 시 §1-B 동작 차이 — Wi-Fi country는 재부팅 시 적용 — 만 합의하면 됨).
+ +

4. 설치 시 주의 (부분배포 사고 방지)

+
    +
  • 드롭인(③)을 설치하면 dpworld-network-apply-hardened.sh도 반드시 함께 설치하세요. 드롭인만 있고 스크립트가 없으면 ExecStart가 없는 파일을 가리켜 펌웨어 네트워크 유닛이 EXEC 실패합니다(멀쩡하던 적용이 깨짐).
  • +
  • web-configurator.service를 설치하면 앱 본체 src/도 함께 설치하세요. 앱이 없으면 서비스가 재시작 루프에 빠집니다.
  • +
  • 쓰기 가능 경로: 앱 코드는 read-only rootfs(/usr/lib/web-configurator)에 두지만, 아래 런타임 경로는 쓰기 가능한 영속 파티션(/opt, /home/root)에 있어야 합니다. +
      +
    • DB /home/root/db · 네트워크 렌더 /home/root/network (dpworldapp과 공유 — 그대로)
    • +
    • 로그 /opt/log/dpworldapp(유닛 LOG_DIR) · 펌웨어 staging/upload /opt/fw_staging·/opt/fw_upload · 설정 백업 /opt/config_backups (유닛 ReadWritePaths 참조)
    • +
    • hardened.sh 상태/로그 STATE_DIR=/opt/dpworld-network (펌웨어 원본엔 없던 신규 경로 — /opt가 늦게 마운트되면 로그/reboot 마커만 유실, 적용 자체는 진행). /opt 마운트 보장(RequiresMountsFor=/opt)을 권장
    • +
    +
  • +
+ +

5. 전체 파일 분류표 (요약)

+ + + + + + + + + + + + + + + +
deploy/ 파일그룹필요성펌웨어 유닛 접촉
web-configurator.serviceA 코어필수무접촉(신규 유닛)
dpworld-network-apply-hardened.shB 네트워크라이브 적용 시 필요무접촉(파일)
dpworld-network-apply.service.d/20-hardened.confB 네트워크③에서만★ override
dpworld-network-seed.service.d/20-hardened.confB 네트워크③에서만★ override
dpworld-network-apply-ondemand.confB 네트워크③에서만★ override
dpworld-net-recover.serviceB 네트워크옵션(복구 안전망)무접촉(신규 유닛)
dpworld-ap-apply.shC APAP 시 필수무접촉(파일)
dpworld-ap-apply.serviceC APAP 시 필수무접촉(신규 유닛)
dpworld-hostapd-ap0.serviceC APAP 시 필수무접촉(신규 유닛)
dpworld-udhcpd-ap0.serviceC APAP 시 필수무접촉(신규 유닛)
dpworld-ap-seed.serviceC AP옵션(재부팅 후 AP 자동복구)무접촉(신규 유닛)
+
★ 표시(드롭인 3종)만이 펌웨어 소유 유닛을 건드립니다 = "충돌"의 전부. 이 3개를 빼면 펌웨어/dpworldapp 자원은 0개 건드리지 않습니다.
nginx.conf는 v1.12.1에서 패키지에서 제거됨(§2) — deploy/ 구성요소 아님.
+ +

부록: dpworldapp과의 공유·격리 요약

+
    +
  • 공유(의도된 연동): DB /home/root/db, 네트워크 렌더 /home/root/network — 웹과 dpworldapp이 동일 파일 계약(byte 호환)을 공유. 웹이 편집·저장하고 dpworldapp이 소비하는 구조(충돌 아님).
  • +
  • 격리(신규, 무접촉): 웹 앱(:9090), AP(ap0 전용 + 방화벽), 복구 유닛, 텔레메트리 Uplink(OS 호스트 라우트만) — 전부 신규 자원.
  • +
  • 유일한 접점: dpworld-network-apply.service/-seed.service(펌웨어 소유) — 라이브 적용을 위해 ③ 방식에서만 override. ① 또는 ②를 택하면 이 접점도 사라집니다.
  • +
+ + + +
+ + diff --git a/docs/deploy-integration-guide.md b/docs/deploy-integration-guide.md new file mode 100644 index 0000000..f0e9448 --- /dev/null +++ b/docs/deploy-integration-guide.md @@ -0,0 +1,126 @@ +# Web Configurator — `deploy/` 통합 가이드 (협력사 전달용) + +> **목적**: `deploy/` 폴더의 구성요소 중 **꼭 필요한 것**과 그 **기능**을 정리하고, 기존 `dpworldapp`/펌웨어와의 **"충돌" 오해**를 해소합니다. +> **대상 버전**: v1.12.1 · **검증**: `deploy/` 통합 의미론은 v1.11.15 기준 소스 코드 + 실디바이스(192.168.55.54, 펌웨어 원본 상태) 대조 완료. v1.12.0 설치 경로 이전(`/usr/lib`)과 v1.12.1 `nginx.conf` 제거를 반영했습니다. + +--- + +## 0. 한눈에 — 핵심 3가지 + +1. **웹 설정기 앱 자체는 네트워크/AP 파일 없이도 단독으로 동작합니다** (`:9090`). 앱은 `src/`(파이썬) + `web-configurator.service` 두 가지만 있으면 기동·설정 저장이 됩니다. +2. **"충돌"의 실체는 단 3개 파일**(네트워크 드롭인)이 *펌웨어가 소유한 유닛을 덮어쓰는 것*뿐입니다. 그 외 모든 파일은 **새로 추가(additive)**되는 것이라 기존 자원을 전혀 건드리지 않습니다 — 크래시·에러를 내지 않습니다. +3. 따라서 **원하는 기능만 골라 설치**하면 충돌 없이 통합됩니다. (§3에 3가지 통합 방식) + +> **경로 표준 (v1.12.0~, 협력사 협의 반영)**: 앱 코드는 **`/usr/lib/web-configurator`** 에 설치합니다 — FHS상 `/usr/lib` 가 read-only program code 자리이며, BSP가 이미지에 굽는 read-only rootfs에 적합합니다. 쓰기 런타임 데이터(로그·펌웨어 staging/upload·설정 백업)는 `/usr/lib`(read-only)가 아니라 **쓰기 가능한 영속 파티션(`/opt`, `/home/root`)** 에 둡니다(§4). +> +> **신규 텔레메트리 Uplink (v1.12.1)** 는 런타임에 OS 호스트 라우트(/32)만 조작하므로 **`deploy/` 추가 구성요소가 필요 없습니다** — 새 유닛/스크립트 없이 기존 `web-configurator.service`(root 권한) 만으로 동작합니다. + +--- + +## 1. 기능 그룹과 필요한 파일 + +`deploy/`의 구성요소는 **3개 기능 그룹**으로 나뉩니다. 그룹 단위로 켜고 끌 수 있습니다. + +### A. 웹 설정기 (필수 — 항상 설치) + +| 파일 | 기능 | 설치 위치 | +|---|---|---| +| (앱 본체 `src/`) | 파이썬 stdlib HTTP 서버. 웹 UI + 설정 API. `:9090` 리슨 | `/usr/lib/web-configurator/src` *(표준 — read-only rootfs 가능; 쓰기 데이터는 §4)* | +| `web-configurator.service` | 위 앱을 부팅 자동기동 + 크래시 시 재시작하는 systemd 유닛 | `${systemd_system_unitdir}` | + +이 그룹만으로 웹 설정기가 동작합니다(설정은 DB `/home/root/db`에 저장). **`dpworldapp`과 충돌하지 않습니다.** + +### B. 네트워크 즉시(라이브) 적용 — 선택 + +> **왜 필요한가**: `dpworldapp`은 **재기동 시에만** 네트워크/설정을 OS에 반영합니다. 이 그룹은 웹 UI에서 IP·Wi-Fi(SSID/비밀번호)를 바꾸면 **재부팅·dpworldapp 재기동 없이 즉시** 반영하기 위한 **보완** 구성입니다. +> +> **동작 메커니즘**: 웹이 렌더 파일을 `/home/root/network/`에 기록 → 적용 스크립트가 그것을 `/run/systemd/network/`로 동기화 + `networkctl`/`wpa_cli` 재구성. (※ `networkctl`은 `/home/root/network`를 직접 읽지 않으므로 이 **동기화 스크립트가 있어야** 라이브 적용이 됩니다.) + +| 파일 | 기능 | 설치 위치 | +|---|---|---| +| `dpworld-network-apply-hardened.sh` | 네트워크 적용 스크립트. **펌웨어 원본의 안전 버전** — Wi-Fi country 변경 시 `modprobe -r wlan`(QCA6490 워치독 리부팅 루프 유발)을 제거하고 country를 **재부팅 시 적용(reboot-deferred)**으로 처리 | `/usr/bin/` (0755) | +| `dpworld-net-recover.service` *(옵션)* | wlan 모듈이 완전히 내려갔을 때 복구(`modprobe` + `wpa` 재시작). 웹/워치독이 on-demand로 호출 | `${systemd_system_unitdir}` | + +> 위 스크립트를 **어떻게** 펌웨어 적용 경로에 연결할지(드롭인 vs 전용 유닛 vs 미사용)는 **§3**에서 선택합니다. 드롭인 3종은 §3 옵션 ③에서만 사용합니다. + +### C. Wi-Fi AP 모드 — 선택 + +> **기능**: 장치를 Wi-Fi AP로 띄워 작업자가 휴대폰/노트북으로 직접 접속(현장 provisioning). `wlan0`(STA)은 건드리지 않고 **`ap0` 가상 인터페이스**를 추가해 사용. hostapd + udhcpd + **ap0 전용 방화벽**(INPUT 전체 개방·WPA2 PSK가 접근 게이트 / FORWARD 차단 = AP 클라이언트의 내부망·PLC(eth1) 경유 차단). + +| 파일 | 기능 | 설치 위치 | +|---|---|---| +| `dpworld-ap-apply.sh` | AP 기동/해제 스크립트(ap0 생성·IP·방화벽·hostapd/udhcpd 시작) | `/usr/bin/` (0755) | +| `dpworld-ap-apply.service` | 웹이 on-demand로 호출하는 AP 적용 유닛 (`[Install]` 없음 = 부팅 자동기동 안 함) | `${systemd_system_unitdir}` | +| `dpworld-hostapd-ap0.service` | `ap0`에서 hostapd 실행 (`[Install]` 없음, ap-apply.sh가 기동) | `${systemd_system_unitdir}` | +| `dpworld-udhcpd-ap0.service` | `ap0`에서 DHCP 서버 실행 (`[Install]` 없음, ap-apply.sh가 기동) | `${systemd_system_unitdir}` | +| `dpworld-ap-seed.service` *(옵션)* | 재부팅 후 AP를 자동 재기동(복구). **없으면 재부팅 후 AP 수동 재활성 필요** | `${systemd_system_unitdir}` | + +> AP 그룹은 **전적으로 신규 구성**입니다. `dpworldapp`/펌웨어 자원을 건드리지 않으며, `ap0` 전용이라 STA·eth·`dpworldapp`과 충돌하지 않습니다. + +--- + +## 2. 제외 / 참고 + +| 파일 | 사유 | +|---|---| +| `deploy/nginx.conf` | **v1.12.1에서 패키지에서 제거됨**. 앱은 `:9090`에서 직접 서비스되므로 reverse proxy 없이 동작합니다. 협력사가 자체 nginx로 `:80` 프런트할 경우, 협력사 설정에서 `proxy_pass http://127.0.0.1:9090/`만 잡으면 됩니다(앱 포트는 `:9090` — 레거시 `:8080` Java app-runner 아님). | +| `scripts/board_trace*.sh` | (`deploy/` 밖이지만 참고) board_config를 폴링해 `/tmp`에 기록하는 **개발 진단용** 스크립트 — 운영 배포물에서 제거 권장 | + +--- + +## 3. "충돌" 해소 — 네트워크 적용 통합 3가지 방식 + +**"충돌"은 §1-B의 라이브 적용을 *펌웨어 유닛을 빌려서* 수행하기 때문에 발생합니다.** 웹은 적용을 `dpworld-network-apply.service`(펌웨어 소유 유닛) 이름으로 호출하므로, 그 유닛이 우리 스크립트를 실행하게 하려면 유닛을 건드려야 합니다. 아래 3가지 중 선택하세요. + +| 방식 | 설치 파일 | 펌웨어 유닛 접촉 | 라이브 적용 | 비고 | +|---|---|---|---|---| +| **① 저장 전용** | 네트워크 파일 **0개** | **없음(무접촉)** | ✗ (dpworldapp 재기동 시 반영) | 웹은 설정 편집·저장만. 충돌 0. 가장 단순 | +| **② 전용 유닛 (권장)** | `dpworld-network-apply-hardened.sh` + 신규 **웹 전용 유닛** | **없음(무접촉)** | ✓ | 웹이 펌웨어 유닛 대신 **자체 유닛**으로 적용. **웹 소규모 코드 변경 필요**(적용 유닛명을 `NET_APPLY_SERVICE` env로 분리 — 현재는 `dpworld-network-apply.service`로 하드코딩) | +| **③ 드롭인 override (현재 패키지)** | `dpworld-network-apply-hardened.sh` + 드롭인 3종 | **있음(override)** | ✓ | 펌웨어 유닛의 ExecStart/의존성을 우리 것으로 교체. 이것이 협력사가 본 "충돌" | + +**드롭인 3종(③에서만 사용):** +- `dpworld-network-apply.service.d/20-hardened.conf` — 펌웨어 `dpworld-network-apply.service`의 `ExecStart`를 hardened.sh로 교체 +- `dpworld-network-seed.service.d/20-hardened.conf` — 펌웨어 `dpworld-network-seed.service`(부팅 seed)를 `hardened.sh --boot`로 교체 +- `dpworld-network-apply-ondemand.conf` — 위 유닛의 `Requires=`(boot-only seed 의존)를 비워 on-demand 기동 허용 + +> **권장**: 펌웨어 베이킹(BSP)에서는 하드닝본을 `/usr/bin/dpworld-network-apply.sh` **원본 이름으로 직접 교체**하는 방식이 1차입니다(드롭인 미설치 — `BSP-INTEGRATION.md` §7 ①). 펌웨어 유닛을 못 건드리는 **라이브 디바이스**에서만 드롭인 override(③)를 fallback으로 씁니다. 라이브 적용이 불필요하면 **① 저장 전용**. (③/직접교체 시 §1-B 동작 차이 — Wi-Fi country는 재부팅 시 적용 — 만 합의하면 됨). + +--- + +## 4. 설치 시 주의 (부분배포 사고 방지) + +- **드롭인(③)을 설치하면 `dpworld-network-apply-hardened.sh`도 반드시 함께** 설치하세요. 드롭인만 있고 스크립트가 없으면 ExecStart가 없는 파일을 가리켜 **펌웨어 네트워크 유닛이 EXEC 실패**합니다(멀쩡하던 적용이 깨짐). +- **`web-configurator.service`를 설치하면 앱 본체 `src/`도 함께** 설치하세요. 앱이 없으면 서비스가 재시작 루프에 빠집니다. +- **쓰기 가능 경로**: 앱 코드는 read-only rootfs(`/usr/lib/web-configurator`)에 두지만, 아래 런타임 경로는 **쓰기 가능한 영속 파티션**(`/opt`, `/home/root`)에 있어야 합니다. + - DB `/home/root/db` · 네트워크 렌더 `/home/root/network` *(dpworldapp과 공유 — 그대로)* + - 로그 `/opt/log/dpworldapp`(유닛 `LOG_DIR`) · 펌웨어 staging/upload `/opt/fw_staging`·`/opt/fw_upload` · 설정 백업 `/opt/config_backups` *(유닛 `ReadWritePaths` 참조)* + - hardened.sh 상태/로그 `STATE_DIR=/opt/dpworld-network` *(펌웨어 원본엔 없던 신규 경로 — `/opt`가 늦게 마운트되면 로그/reboot 마커만 유실, 적용 자체는 진행). `/opt` 마운트 보장(`RequiresMountsFor=/opt`)을 권장* + +--- + +## 5. 전체 파일 분류표 (요약) + +| `deploy/` 파일 | 그룹 | 필요성 | 펌웨어 유닛 접촉 | +|---|---|---|---| +| `web-configurator.service` | A 코어 | **필수** | 무접촉(신규 유닛) | +| `dpworld-network-apply-hardened.sh` | B 네트워크 | 라이브 적용 시 필요 | 무접촉(파일) | +| `dpworld-network-apply.service.d/20-hardened.conf` | B 네트워크 | **③에서만** | ★ override | +| `dpworld-network-seed.service.d/20-hardened.conf` | B 네트워크 | **③에서만** | ★ override | +| `dpworld-network-apply-ondemand.conf` | B 네트워크 | **③에서만** | ★ override | +| `dpworld-net-recover.service` | B 네트워크 | 옵션(복구 안전망) | 무접촉(신규 유닛) | +| `dpworld-ap-apply.sh` | C AP | AP 시 필수 | 무접촉(파일) | +| `dpworld-ap-apply.service` | C AP | AP 시 필수 | 무접촉(신규 유닛) | +| `dpworld-hostapd-ap0.service` | C AP | AP 시 필수 | 무접촉(신규 유닛) | +| `dpworld-udhcpd-ap0.service` | C AP | AP 시 필수 | 무접촉(신규 유닛) | +| `dpworld-ap-seed.service` | C AP | 옵션(재부팅 후 AP 자동복구) | 무접촉(신규 유닛) | + +> ★ 표시(드롭인 3종)만이 펌웨어 소유 유닛을 건드립니다 = "충돌"의 전부. 이 3개를 빼면 펌웨어/`dpworldapp` 자원은 **0개** 건드리지 않습니다. +> *`nginx.conf`는 v1.12.1에서 패키지에서 제거됨(§2) — deploy/ 구성요소 아님.* + +--- + +## 부록: dpworldapp과의 공유·격리 요약 + +- **공유(의도된 연동)**: DB `/home/root/db`, 네트워크 렌더 `/home/root/network` — 웹과 `dpworldapp`이 동일 파일 계약(byte 호환)을 공유. 웹이 편집·저장하고 `dpworldapp`이 소비하는 구조(충돌 아님). +- **격리(신규, 무접촉)**: 웹 앱(`:9090`), AP(`ap0` 전용 + 방화벽), 복구 유닛, 텔레메트리 Uplink(OS 호스트 라우트만) — 전부 신규 자원. +- **유일한 접점**: `dpworld-network-apply.service`/`-seed.service`(펌웨어 소유) — 라이브 적용을 위해 ③ 방식에서만 override. ① 또는 ②를 택하면 이 접점도 사라집니다. diff --git a/docs/network-apply-engine-guide.md b/docs/network-apply-engine-guide.md index b55c3fa..aac2e05 100644 --- a/docs/network-apply-engine-guide.md +++ b/docs/network-apply-engine-guide.md @@ -1160,7 +1160,7 @@ StartLimitIntervalSec=60 StartLimitBurst=5 [Service] -ExecStart=/usr/bin/python3 /opt/web-configurator/src/server.py +ExecStart=/usr/bin/python3 /usr/lib/web-configurator/src/server.py Environment=DB_PATH=/home/root/db/dynamic_data.db Environment=LOG_DIR=/opt/log/dpworldapp Environment=PORT=9090 @@ -1175,7 +1175,7 @@ RestartSec=5 |------|---------|------------------| | `After=`/`Wants=` | `wpa_supplicant@wlan0.service` | watchdog 첫 틱에서 `/var/run/wpa_supplicant` 제어 소켓이 보이도록 기동 순서 보장 — 부팅 race 로 인한 wpa 쿼리 실패 → production WiFi flap 방지([§5.2](#5-watchdog--self-healing--상시-감시자가복구), `web-configurator.service:8-11`) | | `PrivateTmp=no` | 공유 `/tmp` 사용 | `wpa_cli` 가 응답 수신용 클라이언트 소켓을 `/tmp/wpa_ctrl_` 에 bind 하는데(컴파일 고정), 사설 tmpfs 는 호스트의 `wpa_supplicant` 가 못 봐서 영구 무응답이 됨. 따라서 공유 `/tmp` 로 전환하고 `ReadWritePaths` 에 `/tmp` 를 명시. v1.4.6.6 의 log-download `/tmp` staging 도 같은 라인이 유지(`web-configurator.service:31-39`) | -| `ProtectSystem=strict` + `ReadWritePaths` | 화이트리스트 | `strict` 는 `/tmp` 까지 RO 로 만들므로 쓰기 경로를 명시해야 함. 화이트리스트: `/home/root/db`, `/opt/log/dpworldapp`, `/opt/web-configurator`, `/opt/fw_staging`, `-/opt/config_backups`, `-/home/root/network`, `/tmp`(`:39`) | +| `ProtectSystem=strict` + `ReadWritePaths` | 화이트리스트 | `strict` 는 `/tmp` 까지 RO 로 만들므로 쓰기 경로를 명시해야 함. 화이트리스트: `/home/root/db`, `/opt/log/dpworldapp`, `/opt/fw_staging`, `-/opt/config_backups`, `-/home/root/network`, `/tmp`(`:39`) | | wpa 제어 소켓 RW | `-/var/run/wpa_supplicant -/run/wpa_supplicant` | `/var/run` 은 `/run` 의 symlink — namespace 는 실경로 기준이므로 둘 다 지정(`:40-41`) | | `MemoryMax=48M` | 메모리 캡 | v1.1.1 안정화 도입. dpworldapp 등 펌웨어 프로세스와의 공존 — amss.bin/펌웨어 MD5 read 시 1MB 청크 강제 등이 이 캡 전제([§5.5](#5-watchdog--self-healing--상시-감시자가복구), `:24`) | | 추가 샌드박싱 | `NoNewPrivileges`, `ProtectHome=read-only`, `PrivateDevices`, `ProtectKernelTunables/Modules/ControlGroups`, `RestrictSUIDSGID`, `LockPersonality` | OS 레벨 최소비용 sandboxing(`:42-46`) | @@ -1212,7 +1212,7 @@ Requires= ### 9.2 `deploy.ps1` 네트워크 단계 -근거: `scripts/deploy.ps1`. 고정 사실: `$AppDir=/opt/web-configurator`, `$Service=web-configurator`, `$Port=9090`, SSH 는 `root@` + `StrictHostKeyChecking=no -o BatchMode=yes`(`deploy.ps1:31-35`). +근거: `scripts/deploy.ps1`. 고정 사실: `$AppDir=/usr/lib/web-configurator`, `$Service=web-configurator`, `$Port=9090`, SSH 는 `root@` + `StrictHostKeyChecking=no -o BatchMode=yes`(`deploy.ps1:31-35`). 배포 순서의 핵심 설계는 **유닛/디렉토리 설치를 src swap 전에 수행** 하는 것이다 — scp 실패 시 OLD src 가 디스크에 살아있는 상태로 중단되어 롤백 창을 보호한다(`deploy.ps1:173-174`). @@ -1224,13 +1224,13 @@ Requires= 4. `10-ondemand.conf` drop-in 설치(`:229-248`): `mkdir -p .../dpworld-network-apply.service.d` 후 hash-compare scp + `daemon-reload`. 디렉토리도 영속 `/lib` 하에 둔다. 5. swap & 검증(`:250-291`): `rm -rf src && mv _deploy_tmp/src src` → 버전 스탬프(`DEPLOYED_VERSION`, `deploy-history.log`) → `systemctl restart` + `Confirm-Health`. health 실패 시 **자동 롤백**(`backups/src-$ts` 복원 + 재시작, `DEPLOYED_VERSION` 을 `version=rolled-back` 으로 갱신). src 백업은 최근 3개 유지(`:155-164`). -검증: `ssh root@ cat /opt/web-configurator/DEPLOYED_VERSION`(`:300`). +검증: `ssh root@ cat /usr/lib/web-configurator/DEPLOYED_VERSION`(`:300`). ### 9.3 경로 / 포트 (운영 .56 기준) | 항목 | 값 | 출처 (env / 기본값) | |------|-----|---------------------| -| HTTP 포트 | `9090` | `PORT` env(유닛에서 9090 지정; 코드 기본은 8080) — `server.py:79`, `web-configurator.service:23` | +| HTTP 포트 | `9090` | `PORT` env(유닛에서 9090 지정; 코드 기본도 9090) — `server.py:86`, `web-configurator.service:28`. 디바이스의 `:8080` 은 별개의 레거시 Java app-runner | | 바인드 주소 | `0.0.0.0`(기본) | `HOST` env(`server.py:78`) — 보안 완화는 §9.5 참조 | | 설정 DB | `/home/root/db/dynamic_data.db` | `DB_PATH` env(`server.py:92`) | | 로그 디렉토리 | `/opt/log/dpworldapp` | `LOG_DIR` env(`server.py:154`) | @@ -1283,7 +1283,7 @@ eth1(운영자 접속 인터페이스로 가정)이 변경 대상에 포함되 #### 9.4.5 Soak / 운영 확인 명령 - 서비스 상태: `ssh root@192.168.55.56 systemctl status web-configurator`. -- 헬스/버전: `curl http://192.168.55.56:9090/api/system-status`(network_apply provider 가 watchdog/drift/interfaces 를 fail-soft 로 포함, `server.py:177-190`), 그리고 `cat /opt/web-configurator/DEPLOYED_VERSION`. +- 헬스/버전: `curl http://192.168.55.56:9090/api/system-status`(network_apply provider 가 watchdog/drift/interfaces 를 fail-soft 로 포함, `server.py:177-190`), 그리고 `cat /usr/lib/web-configurator/DEPLOYED_VERSION`. - 네트워크 종합/drift: `curl 'http://192.168.55.56:9090/api/network/state'`. - 저널 tail(이벤트 추적, soak 관찰): `curl 'http://192.168.55.56:9090/api/network/journal?limit=50'`(최대 500, psk/password 마스킹됨, `net_routes.py:78-83`, `journal.py:6,30-35`). 파일 직접: `/opt/log/dpworldapp/network_journal.jsonl`(+ `.1/.2/.3` 로테이션, 5MB×3). - watchdog heartbeat 는 정상 시 1시간마다만 디스크 기록(정상 틱은 RAM 카운터) — soak 중 heartbeat 라인 존재로 생존 확인(`watchdog.py:269-272`). diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 7de64d6..c3f1bee 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -28,7 +28,10 @@ param( $ErrorActionPreference = 'Stop' # --- Fixed device facts --------------------------------------------------- -$AppDir = '/opt/web-configurator' +# v1.12.0: 앱 코드 디렉토리 /opt/web-configurator → /usr/lib/web-configurator (협력사 BSP +# 베이킹 정합). 이 장비에서 /usr/lib 는 /lib/systemd/system 과 동일한 영속 RW overlay 라 +# in-place src swap·backups/·DEPLOYED_VERSION 기록이 그대로 동작한다(쓰기 산출물은 $AppDir 하 유지). +$AppDir = '/usr/lib/web-configurator' $Service = 'web-configurator' $Port = 9090 $SshTarget = "root@$DeviceIp" diff --git a/src/config_validator.py b/src/config_validator.py index 0dfc1e5..53e4226 100644 --- a/src/config_validator.py +++ b/src/config_validator.py @@ -575,21 +575,26 @@ def _validate_register_entry(entry, label, errors, require_addr=False, require_i # idt: case-insensitive membership (lowercase before check) idt = entry.get("idt") if idt is not None and idt != "": - if idt.lower() not in _REGISTER_IDT_VALUES: + if not isinstance(idt, str): + errors.append(f"{label}.idt must be string") + elif idt.lower() not in _REGISTER_IDT_VALUES: errors.append(f"{label}.idt={idt!r} not in reader idt set") # odt: accept synonyms float/double→float64, bool→boolean (case-insensitive) odt = entry.get("odt") _odt_normalized = None if odt is not None and odt != "": - _odt_lower = odt.lower() - if _odt_lower in ("float", "double"): - _odt_normalized = "float64" - elif _odt_lower == "bool": - _odt_normalized = "boolean" + if not isinstance(odt, str): + errors.append(f"{label}.odt must be string") else: - _odt_normalized = _odt_lower - if _odt_normalized not in _REGISTER_ODT_VALUES: - errors.append(f"{label}.odt={odt!r} not in reader odt set") + _odt_lower = odt.lower() + if _odt_lower in ("float", "double"): + _odt_normalized = "float64" + elif _odt_lower == "bool": + _odt_normalized = "boolean" + else: + _odt_normalized = _odt_lower + if _odt_normalized not in _REGISTER_ODT_VALUES: + errors.append(f"{label}.odt={odt!r} not in reader odt set") # dv — numeric normally; arbitrary string allowed when odt=string dv = entry.get("dv") diff --git a/src/db_manager.py b/src/db_manager.py index 71944de..8997a31 100644 --- a/src/db_manager.py +++ b/src/db_manager.py @@ -40,7 +40,8 @@ class DBManager: """Configuration storage manager with 3-tier backend.""" # Whitelist of allowed config keys (SQL injection defense for CLI backend) - ALLOWED_KEYS = frozenset({'device_config', 'protocol_config', 'log_config', 'net_config', 'ap_config'}) # AP: ap_config key + ALLOWED_KEYS = frozenset({'device_config', 'protocol_config', 'log_config', 'net_config', + 'ap_config', 'uplink_config'}) # uplink: Telemetry Uplink (web-owned) def __init__(self, db_path=None, backend=None): self.db_path = db_path or os.environ.get("DB_PATH", DB_PATH_DEFAULT) diff --git a/src/dpworldapp_telemetry.py b/src/dpworldapp_telemetry.py index 6445ba2..4a92861 100644 --- a/src/dpworldapp_telemetry.py +++ b/src/dpworldapp_telemetry.py @@ -7,7 +7,7 @@ dpworldapp 가 `127.0.0.1:8989` 에 LISTEN 하며 connection 직후 헤더 스 version / MAC / IP) 만 수집한다. 발견 경위 (2026-06-09): -- 6-byte command flow를 TCP telemetry 동작과 비교 검증 +- 레거시 6-byte serial command protocol 을 TCP 로 이식했을 가능성을 탐색 - TCP 포트 inventory 결과 `.54` dpworldapp 가 8989 + 8990 두 포트만 listen (8990 = firmware OTA, 이미 알려진 채널) - 8989 에 6-byte ReadAll/GetFwVersion/GetMacAddress 모두 송신 시 같은 응답 → @@ -103,7 +103,10 @@ def query_telemetry(host=DEFAULT_HOST, port=DEFAULT_PORT, timeout_s=DEFAULT_TIME break buf += chunk # 모든 헤더 frame 수신했으면 조기 종료 (실시간 stream 안 받음 — CPU/network 절약) - if all(k.encode("ascii") in buf for k in HEADER_KEYS): + # Only check the completed (newline-terminated) portion to avoid + # matching a label whose value is still split across chunks. + completed = buf[:buf.rfind(b'\n') + 1] + if completed and all(k.encode("ascii") in completed for k in HEADER_KEYS): break except (socket.error, OSError): return {} diff --git a/src/firmware/fw_routes.py b/src/firmware/fw_routes.py index fbbd084..0e684b2 100644 --- a/src/firmware/fw_routes.py +++ b/src/firmware/fw_routes.py @@ -34,8 +34,17 @@ class RouteError(Exception): class FirmwareRoutes: def __init__(self, controller, tmp_dir=None): + # B14: fail-closed — a missing tmp_dir must never silently fall through + # to gettempdir()/tmpfs. Under MemoryMax=48M with no swap, buffering a + # multi-hundred-MB ZIP on tmpfs OOM-kills the service (v1.10.2 incident). + if not tmp_dir: + raise ValueError( + "tmp_dir is required for FirmwareRoutes; " + "must be a persistent disk path (e.g. /opt/fw_upload), " + "NEVER tmpfs/RAM — see OOM incident v1.10.2" + ) self.fw = controller - self.tmp_dir = tmp_dir or tempfile.gettempdir() + self.tmp_dir = tmp_dir # ---- GET ----------------------------------------------------------- def status(self): diff --git a/src/firmware/protocol.py b/src/firmware/protocol.py index 81cf361..d1cd29b 100644 --- a/src/firmware/protocol.py +++ b/src/firmware/protocol.py @@ -57,7 +57,7 @@ def classify_ack(text): first so a frame carrying both is treated as success. """ upper = text.upper() - if "SUCCESS" in upper or "COMPLETE_ACK" in upper: + if _contains_token(upper, "SUCCESS") or _contains_token(upper, "COMPLETE_ACK"): return "success" if "FW_FAIL" in upper or any(_contains_token(upper, t) for t in _FAILURE_TOKENS): return "failure" diff --git a/src/log_manager.py b/src/log_manager.py index 35b4b41..c49681e 100644 --- a/src/log_manager.py +++ b/src/log_manager.py @@ -75,6 +75,34 @@ def _has_allowed_extension(name): return bool(_ROTATION_SUFFIX_RE.search(name)) +# B12: sentinel returned by _resolve_log_path when the extension check fails. +# Callers use different wording for this case ("File extension not allowed" vs +# "Unsupported extension") so they format the message themselves. +_EXT_DENIED = object() + + +def _resolve_log_path(name): + """Perform the 5-step security gate for a single filename. + + Returns (resolved_path, None) on success. + Returns (None, _EXT_DENIED) if the extension is not whitelisted — callers + must produce their own error message for this case. + Returns (None, error_str) for all other failures. + """ + if not isinstance(name, str) or not name.strip(): + return None, "Invalid filename: empty or non-string" + if '..' in name or '/' in name or '\\' in name: + return None, f"Invalid filename: {name}" + if not _has_allowed_extension(name): + return None, _EXT_DENIED + filepath = os.path.realpath(os.path.join(LOG_DIR, name)) + if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep): + return None, f"Invalid filename: {name}" + if not os.path.isfile(filepath): + return None, f"File not found: {name}" + return filepath, None + + def _make_tar_name(name, mtime): """ Archive name for a log file: ``..tar.gz``. @@ -172,26 +200,11 @@ def validate_filenames(filenames): valid_paths = [] for name in filenames: - if not isinstance(name, str) or not name.strip(): - return None, f"Invalid filename: empty or non-string" - - # Block path traversal characters - if '..' in name or '/' in name or '\\' in name: - return None, f"Invalid filename: {name}" - - # Extension whitelist — block .partial, .sh, etc. - # Also permit uncompressed logrotate suffixes: *.log.0, *.log.1, … - if not _has_allowed_extension(name): + filepath, err = _resolve_log_path(name) + if err is _EXT_DENIED: return None, f"File extension not allowed: {name}" - - # Resolve symlinks and verify it stays within LOG_DIR - filepath = os.path.realpath(os.path.join(LOG_DIR, name)) - if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep): - return None, f"Invalid filename: {name}" - - if not os.path.isfile(filepath): - return None, f"File not found: {name}" - + if err is not None: + return None, err valid_paths.append(filepath) # Check total size limit @@ -489,7 +502,7 @@ def _run_cleanup(max_files, max_size_mb): deleted = 0 total_size = sum(a[1] for a in archives) - while archives and (len(archives) > max_files or total_size > max_size_bytes): + while len(archives) > 1 and (len(archives) > max_files or total_size > max_size_bytes): entry = archives.pop(0) mtime, size, name, path = entry try: @@ -529,6 +542,21 @@ def migrate_log_config(db): key: device.get(key, LOG_CONFIG_DEFAULTS[key]) for key in LOG_CONFIG_KEYS } + # B11: clamp integer fields to the same hard floors enforced by + # validate_log_config_hard — a legacy device_config with sub-floor + # values (e.g. log_cleanup_max_size_mb=0) must not be seeded as-is. + _seed_floors = { + "log_cleanup_max_files": 5, + "log_cleanup_max_size_mb": 50, + "log_compress_size_mb": 1, + "log_compress_age_days": 1, + } + for key, floor in _seed_floors.items(): + v = seeded.get(key) + try: + seeded[key] = max(floor, int(v)) + except (TypeError, ValueError): + seeded[key] = LOG_CONFIG_DEFAULTS[key] db.save_config("log_config", seeded) logger.info("Migrated log-management settings into the log_config key") except Exception as e: @@ -633,27 +661,12 @@ def delete_log_files(filenames): errors = [] for name in filenames: - if not isinstance(name, str) or not name.strip(): - errors.append(f"Invalid filename: empty or non-string") - continue - - # Block path traversal - if '..' in name or '/' in name or '\\' in name: - errors.append(f"Invalid filename: {name}") - continue - - filepath = os.path.realpath(os.path.join(LOG_DIR, name)) - if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep): - errors.append(f"Invalid filename: {name}") - continue - - # v1.3.1: enforce extension whitelist — same set used by validate_filenames - if not _has_allowed_extension(name): + filepath, err = _resolve_log_path(name) + if err is _EXT_DENIED: errors.append(f"Unsupported extension: {name}") continue - - if not os.path.isfile(filepath): - errors.append(f"File not found: {name}") + if err is not None: + errors.append(err) continue try: diff --git a/src/migrations.py b/src/migrations.py index ea5e909..bd2fd18 100644 --- a/src/migrations.py +++ b/src/migrations.py @@ -33,7 +33,8 @@ def migrate_can_baudrate_units(db) -> bool: cfg["can_baudrate"] = int(v.strip()) return cfg - db.update_config("device_config", _mut, default={}) + if db.config_exists("device_config"): + db.update_config("device_config", _mut, default={}) db.set_schema_meta("can_baudrate_units_migrated", "true") return True @@ -112,7 +113,8 @@ def migrate_port_types(db) -> bool: cfg[k] = int(v) return cfg - db.update_config("device_config", _mut, default={}) + if db.config_exists("device_config"): + db.update_config("device_config", _mut, default={}) db.set_schema_meta("port_types_migrated", "true") return True @@ -139,7 +141,8 @@ def migrate_contract_canonical(db) -> bool: cfg["rs485_parity"] = "none" return cfg - db.update_config("device_config", _mut_device, default={}) + if db.config_exists("device_config"): + db.update_config("device_config", _mut_device, default={}) # protocol_config: byte_order camelCase → space + idt float64 → float _BYTE_ORDER_MAP = {"littleSwap": "little swap", "bigSwap": "big swap"} @@ -164,7 +167,8 @@ def migrate_contract_canonical(db) -> bool: cfg[arr_key] = new_arr return cfg - db.update_config("protocol_config", _mut_protocol, default={}) + if db.config_exists("protocol_config"): + db.update_config("protocol_config", _mut_protocol, default={}) db.set_schema_meta("contract_canonical_migrated", "true") return True diff --git a/src/network/ap_engine.py b/src/network/ap_engine.py index c465b90..d18de8c 100644 --- a/src/network/ap_engine.py +++ b/src/network/ap_engine.py @@ -1,6 +1,6 @@ # src/network/ap_engine.py """AP apply 오케스트레이션 (spec §7/§8). 렌더 파일 작성 + dpworld-ap-apply.service 트리거 + 상태 persist.""" -import json, os, threading +import json, os, re, threading from network.ap_model import ap_intent_from_db from network.ap_validator import validate_ap from network.ap_renderer import render_hostapd, render_udhcpd @@ -39,6 +39,72 @@ def parse_iw_link_channel(out): # country 별 2.4GHz 기본 채널(MCC opt-in 시) _DEFAULT_2G = 6 + +def _parse_first_int(text): + m = re.search(r"-?\d+", text or "") + return int(m.group(0)) if m else None + + +def _parse_station_dump(out): + clients = [] + cur = None + for raw in (out or "").splitlines(): + line = raw.strip() + if line.startswith("Station "): + if cur: + clients.append(cur) + parts = line.split() + mac = parts[1].lower() if len(parts) > 1 else "" + cur = {"mac": mac, "ip": None, "signal_dbm": None, "inactive_ms": None, "connected_s": None} + continue + if not cur or ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + if key == "inactive time": + cur["inactive_ms"] = _parse_first_int(value) + elif key == "signal": + cur["signal_dbm"] = _parse_first_int(value) + elif key == "connected time": + cur["connected_s"] = _parse_first_int(value) + if cur: + clients.append(cur) + return [c for c in clients if c.get("mac")] + + +def _client_stub(mac): + return {"mac": mac, "ip": None, "signal_dbm": None, "inactive_ms": None, "connected_s": None} + + +def _parse_hostapd_sta_list(out): + macs = [] + seen = set() + for raw in (out or "").splitlines(): + line = raw.strip().lower() + if not re.fullmatch(r"[0-9a-f]{2}(:[0-9a-f]{2}){5}", line): + continue + if line not in seen: + macs.append(line) + seen.add(line) + return macs + + +def _without_macs(clients, macs): + blocked = {m.lower() for m in macs if m} + return [c for c in clients if c.get("mac", "").lower() not in blocked] + + +def _parse_neigh_by_mac(out): + ips = {} + for raw in (out or "").splitlines(): + parts = raw.split() + if not parts or "lladdr" not in parts: + continue + idx = parts.index("lladdr") + if idx + 1 < len(parts): + ips[parts[idx + 1].lower()] = parts[0] + return ips + class ApEngine: def __init__(self, ap_dir, state_path, runner=None, live_country=None, sta_channel=None, country_pending=None): @@ -61,7 +127,8 @@ class ApEngine: # ap_channel 0(DEFAULT)=auto 센티넬 → 'or'가 기본/STA 채널을 채움(의도된 동작) if band == "2g": return (intent.get("ap_channel") or _DEFAULT_2G, "g") - return (intent.get("ap_channel") or (sta_ch or 36), "a") + sta5 = sta_ch if sta_hw == "a" else None + return (intent.get("ap_channel") or (sta5 or 36), "a") def _persist(self, state, intent): try: @@ -139,13 +206,37 @@ class ApEngine: _rc_h, out_h = self.runner(["systemctl", "is-active", "dpworld-hostapd-ap0.service"], 5) hostapd_running = (out_h or "").strip() == "active" clients = 0 + client_details = [] + ap_mac = "" + rc_m, out_m = self.runner(["cat", "/sys/class/net/ap0/address"], 5) + if rc_m == 0: + ap_mac = (out_m or "").strip().splitlines()[0].lower() if (out_m or "").strip() else "" rc_s, out_s = self.runner(["iw", "dev", "ap0", "station", "dump"], 5) + station_details = [] if rc_s == 0: - clients = sum(1 for ln in (out_s or "").splitlines() if ln.strip().startswith("Station ")) + station_details = _without_macs(_parse_station_dump(out_s), [ap_mac]) + rc_l, out_l = self.runner(["hostapd_cli", "-i", "ap0", "list_sta"], 5) + if rc_l == 0: + macs = _without_macs([_client_stub(m) for m in _parse_hostapd_sta_list(out_l)], [ap_mac]) + if macs or not (out_l or "").strip(): + by_station = {c["mac"]: c for c in station_details} + client_details = [by_station.get(c["mac"], c) for c in macs] + else: + client_details = station_details + else: + client_details = station_details + clients = len(client_details) + if client_details: + rc_n, out_n = self.runner(["ip", "neigh", "show", "dev", "ap0"], 5) + if rc_n == 0: + by_mac = _parse_neigh_by_mac(out_n) + for client in client_details: + client["ip"] = by_mac.get(client["mac"]) return { "ap_enabled": ap_enabled, "ap0_up": ap0_up, "hostapd_running": hostapd_running, "clients": clients, + "client_details": client_details, "country_pending": bool(self._country_pending()), } diff --git a/src/network/ap_routes.py b/src/network/ap_routes.py index 8fe7b7c..16ba0ee 100644 --- a/src/network/ap_routes.py +++ b/src/network/ap_routes.py @@ -8,6 +8,18 @@ class RouteError(Exception): AP_FIELDS = frozenset(DEFAULT_AP_CONFIG.keys()) + +def _req_bool(body, name): + """Fix #2: strict bool parser — matches net_routes._req_bool semantics. + None/absent → False, real bool → that value, anything else → RouteError(400). + Prevents JSON string "false" being truthy (silently activating dry-run).""" + v = body.get(name) + if v is None: + return False + if isinstance(v, bool): + return v + raise RouteError(400, f"{name} must be a boolean") + class ApRoutes: def __init__(self, engine, db): self.engine = engine; self.db = db @@ -24,6 +36,9 @@ class ApRoutes: for k in ("ap_ssid", "ap_passphrase", "ap_ip", "dhcp_start", "dhcp_end", "ap_band"): if k in clean and not isinstance(clean[k], str): raise RouteError(400, f"{k} must be a string") + # blank passphrase = keep existing (frontend blank=keep contract — mirror server-side) + if clean.get("ap_passphrase") == "": + clean.pop("ap_passphrase") def _merge(current): cur = dict(current) if isinstance(current, dict) else {} @@ -37,7 +52,7 @@ class ApRoutes: if not isinstance(body, dict): raise RouteError(400, "body must be an object") cur = self.db.get_config("ap_config") or dict(DEFAULT_AP_CONFIG) - if body.get("dry_run"): + if _req_bool(body, "dry_run"): # Fix #2: strict bool (matches net_routes._req_bool) # Never leak the WPA2 PSK over the unauthenticated API (response only; # storage keeps it — frontend treats a blank field as "keep existing"). safe = dict(cur) @@ -48,6 +63,9 @@ class ApRoutes: def status(self): st = dict(self.engine.status()) cfg = {**DEFAULT_AP_CONFIG, **(self.db.get_config("ap_config") or {})} + # Fix #3a: expose has_passphrase before stripping the PSK — lets the frontend + # detect "no PSK stored" on a fresh device without leaking the secret. + st["has_passphrase"] = bool(cfg.get("ap_passphrase")) # Strip the WPA2 PSK before returning (unauthenticated API). cfg.pop("ap_passphrase", None) st["config"] = cfg diff --git a/src/network/apply_engine.py b/src/network/apply_engine.py index 435a9d6..dde060e 100644 --- a/src/network/apply_engine.py +++ b/src/network/apply_engine.py @@ -222,8 +222,8 @@ class ApplyEngine: country_changed = bool(target_cc) and any(d["field"] == "wlan0.country_code" for d in diff) if prior_pending and not country_now and diff: # C2: 보류 country 가 있는 동안 추가 적용 동결 — deferral 분기로 새는 silent commit 차단 - errs.append("country 변경이 재부팅 대기 중 (§6.2) — 추가 적용은 재부팅 후에, 또는 " - "'country 지금 적용'(country_now) 동의로 보류분과 함께 적용") + errs.append("country change is reboot pending (§6.2) — apply further changes after reboot, or " + "consent to 'apply country now' (country_now) to apply them together with the pending change") # v1.9: mixed country+other (not country_now) 는 더이상 거절하지 않는다 — split-apply 로 처리 (호출측 split 판정). return errs, country_changed @@ -246,6 +246,12 @@ class ApplyEngine: except Exception: # noqa: BLE001 — country gate 실패 → 안전 오류 메시지 gate_errs = ["Cannot evaluate country gate"] errs.extend(gate_errs) + # 선택된 텔레메트리 업링크가 이 apply 로 게이트웨이를 잃으면 거절(라우트 silent 소멸 방지) + from network import uplink as _uplink_dr + _up_iface_dr = _uplink_dr.selected_iface(self._get_uplink_config()) + if _up_iface_dr != "wlan0" and not netmodel.gateway_set(new.get(_up_iface_dr, {})): + errs.append(f"Selected telemetry uplink ({_up_iface_dr}) would lose its gateway " + f"with this change — switch the uplink to Wi-Fi first.") warnings = list(warns) ifaces = netmodel.changed_interfaces(diff) if "eth1" in ifaces: warnings.append("eth1_confirm") @@ -331,6 +337,9 @@ class ApplyEngine: cur_dev = self.db.get_config("device_config") or {} cur_it = self._baseline_intent() # ★ 적용본 기준 (§1.2) new_it = netmodel.intent_from_device({**cur_dev, **new_dev_fields}) + from network import uplink as _uplink # device apply 도 uplink 라우트 보존 (Opt 2 (a)) + _uplink.attach(new_it, {**cur_dev, **new_dev_fields}, self._get_uplink_config(), + live=_uplink.live_subnets(self.runner)) aid = self._cur["apply_id"] country_now = self._cur["country_now"] prior_pending = self._prior_country_pending # I2: _begin 에서 보관 @@ -338,7 +347,7 @@ class ApplyEngine: diff = netmodel.diff_intents(cur_it, new_it) if not diff: self._cur["state"] = "NOOP"; self._persist() # country_pending 은 prior 보존 (I2) - self._step("VALIDATING", "ok", "no change (적용본 == DB)", aid) + self._step("VALIDATING", "ok", "no change (applied == DB)", aid) return {"state": "NOOP", "apply_id": aid} errs, warns = validator.validate(new_it) errs = list(errs) @@ -348,6 +357,11 @@ class ApplyEngine: # C2: 보류 country 동결 게이트 포함 — dry_run 과 공용 헬퍼 (분기 분산 금지). gate_errs, country_changed = self._country_gate_errors(diff, new_it, country_now, prior_pending) errs.extend(gate_errs) + # 선택된 텔레메트리 업링크가 이 apply 로 게이트웨이를 잃으면 거절(라우트 silent 소멸 방지) + _up_iface = _uplink.selected_iface(self._get_uplink_config()) + if _up_iface != "wlan0" and not netmodel.gateway_set(new_it.get(_up_iface, {})): + errs.append(f"Selected telemetry uplink ({_up_iface}) would lose its gateway " + f"with this change — switch the uplink to Wi-Fi first.") if errs: self._cur["state"] = "FAILED_VALIDATION"; self._persist() self._step("VALIDATING", "fail", "; ".join(errs), aid) @@ -365,7 +379,7 @@ class ApplyEngine: f"{len(diff)} fields" + (" [force]" if self._cur["force"] else ""), aid) # §5.3 force 명시 if any(d["field"] in ("wlan0.dns1", "wlan0.dns2") for d in diff): self.journal.event("apply", phase="VALIDATING", action="dns_saved_only", result="warn", - apply_id=aid, detail={"note": "dns1/dns2 는 현 펌웨어 미적용 (§12-3)"}) + apply_id=aid, detail={"note": "dns1/dns2 not applied by current firmware (§12-3)"}) # SNAPSHOT self._cur["state"] = "SNAPSHOT"; self._persist() snap = snapshot.take(self.net_dir, self.backups_dir, aid, self._db_network_fields()) @@ -374,18 +388,20 @@ class ApplyEngine: # WRITING — DB-first (§4.1) self._cur["state"] = "WRITING"; self._persist() self._write_db(new_dev_fields) - if split_country: - # wpa conf 는 적용본(OLD) country 로 — 비country 적용 시 regdomain 라이브 누출 방지. + # country-only + 미지정 → reboot-deferred (§6.2). split 과 동일하게 wpa conf 는 적용본(OLD) + # country 로 렌더해야 함 — 안 그러면 보류 중 watchdog wpa 래더(wpa_cli reconfigure)가 + # NEW regdomain 을 라이브 누출 (review B2). 마커/persist 는 NEW(리부팅 시 firmware 적용). + defer_country_only = (country_changed and not country_now + and len(diff) == 1 and diff[0]["field"] == "wlan0.country_code") + if split_country or defer_country_only: self._write_renders(new_it, wpa_country=cur_it["wlan0"]["country_code"]) - self._cur["country_pending"] = True + if split_country: + self._cur["country_pending"] = True else: self._write_renders(new_it) self._step("WRITING", "ok", "db+json+renders" + (" [split]" if split_country else ""), aid) # country-only + 미지정 → deferred (§6.2): apply.service 미기동 - # C2: deferral 은 diff 가 정확히 country 단독일 때만 — 다른 필드가 이 분기로 새어 - # "COMMITTED 인데 미적용" 이 되는 lockout hole 차단 (혼합은 위 게이트에서 이미 거절) - if country_changed and not country_now \ - and len(diff) == 1 and diff[0]["field"] == "wlan0.country_code": + if defer_country_only: self._cur.update({"state": "COMMITTED", "country_pending": True}); self._persist() self._lkg_bookkeeping(aid) self._step("COMMITTED", "ok", "country deferred — reboot required", aid) @@ -416,13 +432,13 @@ class ApplyEngine: self._run(["systemctl", "restart", "wpa_supplicant@wlan0.service"], 30, aid) target = new_it["wlan0"]["country_code"] if not self.exists("/sys/module/wlan"): - return self._rollback_from(("APPLYING",), "country_now: /sys/module/wlan 부재 (§6.2)") + return self._rollback_from(("APPLYING",), "country_now: /sys/module/wlan missing (§6.2)") if not self.exists("/sys/class/net/wlan0"): - return self._rollback_from(("APPLYING",), "country_now: wlan0 netdev 부재 (§6.2)") + return self._rollback_from(("APPLYING",), "country_now: wlan0 netdev missing (§6.2)") live = self._live_country() # iw reg / sysfs — 라디오 실효 regdomain (v1.9.1: wpa_cli 아님) if live != target: return self._rollback_from(("APPLYING",), - f"country_now: live {live!r} != 목표 {target!r} (§6.2)") + f"country_now: live {live!r} != target {target!r} (§6.2)") # C2: 보류분 동반 적용 확인 — pending 해소 (이후 _persist 들이 False 를 기록) self._cur["country_pending"] = False self._step("APPLYING", "ok", "", aid) @@ -435,7 +451,7 @@ class ApplyEngine: if not self._cur["force"]: return self._rollback_from(("VERIFYING",), "verify failed") # C1: claim 후 본문 self.journal.event("apply", phase="VERIFYING", action="force_commit", result="warn", - apply_id=aid, detail={"note": "verify fail 을 force 로 무시 (§5.3)"}) + apply_id=aid, detail={"note": "verify fail overridden by force (§5.3)"}) # eth1 → CONFIRM_WAIT (§6.1) — 엔진 자체 타이머가 1차 만료 보장 if "eth1" in self._cur["changed_ifaces"]: self._cur.update({"state": "CONFIRM_WAIT", @@ -506,7 +522,7 @@ class ApplyEngine: self.journal.event("apply", phase="ROLLING_BACK", action="rollback_empty_baseline_kept_renders", result="warn", apply_id=aid, - detail={"note": "빈 baseline 스냅샷 — 적용본 렌더 유지 (eth1 lockout 방지)"}) + detail={"note": "empty baseline snapshot — keeping applied renders (eth1 lockout prevention)"}) snapshot.restore_files(snap, self.net_dir) # 재적용 — rollback re-apply: apply.service 는 best-effort (DEF-2a 동일 이유). # rollback re-verify 가 실 게이트 — verify fail → FAILED_CRITICAL (올바른 동작). @@ -564,7 +580,7 @@ class ApplyEngine: self.journal.event("apply", phase="ROLLED_BACK", action="rollback_left_drift", result="warn", apply_id=aid, detail={"fields": [d["field"] for d in drift], - "note": "user-saved 변경이 drift 로 남음 — 재시도 가능"}) + "note": "user-saved change remains as drift — retryable"}) except Exception: # noqa: BLE001 pass self._step("ROLLED_BACK", "ok", "", aid) @@ -664,6 +680,171 @@ class ApplyEngine: return bool(self._cur.get("country_pending")) return self._read_persisted_pending() + # ── uplink (Opt 2: 전용 경로, device diff 머신 무접촉) ────────── + def _get_uplink_config(self): + try: + uc = self.db.get_config("uplink_config") + except Exception: # noqa: BLE001 + uc = None + return uc if isinstance(uc, dict) else {} + + def _set_uplink_config(self, uc): + def mut(_cur): + return dict(uc) if isinstance(uc, dict) else {} + self.db.update_config("uplink_config", mut, default={}) + + def _uplink_intent(self, iface): + from network import uplink as _uplink + dev = self.db.get_config("device_config") or {} + # review U1: iface 설정은 '적용본' baseline(network_config.json) 기준 — Save 가 갱신한 + # (미적용) device_config 드리프트를 uplink 전환이 몰래 적용/락아웃하거나 diff baseline 을 + # 오염시키는 것 차단. baseline 부재(최초 적용 전)일 때만 DB 로 폴백. telemetry 대상 IP 는 + # dev(device_config)에서 읽음(attach) — 라우트는 실제 서버 IP 를 향해야 하므로. + try: + with open(os.path.join(self.net_dir, "network_config.json"), encoding="utf-8") as f: + doc = json.load(f) + # codex C2: 부분/빈 persist 는 유효 baseline 아님 — render_persist_json 은 항상 세 iface + # dict 를 내보내므로, 셋 다 dict 가 아니면 손상/부분으로 보고 device_config 로 폴백 + # (빈 baseline 으로 iface 렌더를 비우는 wipe 차단). _baseline_intent(diff용)는 무관. + if not (isinstance(doc, dict) + and all(isinstance(doc.get(k), dict) for k in ("wlan0", "eth0", "eth1"))): + raise ValueError("incomplete network_config.json baseline") + it = renderer.intent_from_persist(doc) + except (OSError, ValueError): + it = netmodel.intent_from_device(dev) + _uplink.attach(it, dev, {"telemetry_iface": iface, "mode": "route"}, + live=_uplink.live_subnets(self.runner)) + return it + + def _begin_uplink(self, iface): + """BUSY 가드 + _cur 셋업. 반환 (started, aid, affected, prior_uc). + country_pending 은 _read_persisted_pending() 으로 보존(Codex #2 — reboot-deferred country 가 + uplink 적용으로 silent clear 되는 것 차단; apply_uplink 는 _begin() 을 안 타므로 여기서 직접 보존).""" + from network import uplink as _uplink + with self._lock: + if self._cur and self._cur["state"] in _ACTIVE + ("CONFIRM_WAIT",): + return False, self._cur["apply_id"], None, None + self._seq += 1 + aid = f"up-{time.strftime('%Y%m%d-%H%M%S')}-{int(self.clock()) % 1000}-{self._seq}" + prior_uc = self._get_uplink_config() + prior_pending = self._read_persisted_pending() + old_iface = _uplink.selected_iface(prior_uc) + affected = sorted({old_iface, iface} - {_uplink.DEFAULT_IFACE}) + if iface == _uplink.DEFAULT_IFACE: + # Codex: wlan0 선택 = stale 복구 케이스. affected가 비면 _run_uplink가 + # 어떤 iface도 reconfigure 안 함 → networkd가 기존 /32 route를 유지. + # 보수적으로 eth0/eth1 둘 다 reconfigure하여 새 (uplink-less) 렌더로 드롭. + affected = sorted({"eth0", "eth1"}) + self._steps = [] + self._cur = {"apply_id": aid, "state": "SNAPSHOT", "force": False, "country_now": False, + "started_monotonic": self.clock(), "confirm_deadline_monotonic": None, + "changed_ifaces": affected, "wpa_changed": False, "country_changed": False, + "country_pending": prior_pending, "snapshot_dir": None, "boot_id": self._boot_id(), + "uplink": {"iface": iface, "old_iface": old_iface}} + self._persist() + return True, aid, affected, prior_uc + + def apply_uplink(self, iface): + """동기 uplink 적용(단위테스트·내부). route-only → CONFIRM_WAIT 없음. + snapshot/_write_renders/_run/verify_fn 재사용. iface 는 검증된 값(post_uplink 가 validate_selection 선수행).""" + if self.country_pending(): # codex C1: country 재부팅 보류 중 — 수동 uplink 차단 + return {"state": "COUNTRY_PENDING"} + started, aid, affected, prior_uc = self._begin_uplink(iface) + if not started: + return {"state": "BUSY", "apply_id": aid} + return self._run_uplink(aid, iface, affected, prior_uc) + + def apply_uplink_async(self, iface): + """Codex #8: STARTED+apply_id 즉시 반환 후 데몬 스레드 — eth1 reconfigure 가 관리 HTTP 끊는 것 방지. + status 폴링은 기존 /api/network/apply/status 재사용. API(post_uplink)는 이걸 호출.""" + if self.country_pending(): # codex C1: country 재부팅 보류 중 — 수동 uplink 차단 + return {"state": "COUNTRY_PENDING"} + started, aid, affected, prior_uc = self._begin_uplink(iface) + if not started: + return {"state": "BUSY", "apply_id": aid} + self._thread = threading.Thread(target=self._run_uplink, args=(aid, iface, affected, prior_uc), + daemon=True, name="net-uplink-apply") + self._thread.start() + return {"state": "STARTED", "apply_id": aid} + + def _run_uplink(self, aid, iface, affected, prior_uc): + try: + snap = snapshot.take(self.net_dir, self.backups_dir, aid, self._db_network_fields()) + self._cur["snapshot_dir"] = snap; self._persist() + self._step("SNAPSHOT", "ok", snap, aid) + self._cur["state"] = "WRITING"; self._persist() + self._set_uplink_config({"telemetry_iface": iface, "mode": "route"}) + new_it = self._uplink_intent(iface) + from network import uplink as _uplink # codex C3: engine-side 검증 — watchdog/내부 호출자도 차단 + verrs = _uplink.validate_selection(iface, new_it) + if verrs: + return self._rollback_uplink("uplink validation failed: " + "; ".join(verrs), + prior_uc, affected) + self._write_renders(new_it) + self._step("WRITING", "ok", f"uplink={iface} affected={affected}", aid) + self._cur["state"] = "APPLYING"; self._persist() + self._run(["systemctl", "start", "dpworld-network-apply.service"], + APPLY_SERVICE_TIMEOUT, aid, must_ok=False) + self._run(["networkctl", "reload"], 15, aid) + for ifc in affected: + self._run(["networkctl", "reconfigure", ifc], 15, aid) + self._step("APPLYING", "ok", "", aid) + self._cur["state"] = "VERIFYING"; self._persist() + vres = self.verify_fn(new_it, affected) + self._step("VERIFYING", vres["result"], + json.dumps(vres["checks"], ensure_ascii=False)[:500], aid) + if vres["result"] == "fail": + return self._rollback_uplink("uplink verify failed", prior_uc, affected) + self._cur["state"] = "COMMITTED"; self._persist() + # Codex #3 Option B: uplink 는 LKG 미마킹(device-network 전용 LKG DB↔파일 desync 방지). + # source-of-truth = uplink_config + watchdog 재assert. prune 만(fail-soft). + try: + snapshot.prune(self.backups_dir) + except Exception: # noqa: BLE001 + pass + self._step("COMMITTED", "ok", "", aid) + return {"state": "COMMITTED", "apply_id": aid, "uplink": iface} + except Exception as e: # noqa: BLE001 + if self._cur.get("snapshot_dir") is None: + with self._lock: + self._cur["state"] = "ABORTED"; self._persist() + return {"state": "ABORTED", "apply_id": aid, "reason": str(e)} + return self._rollback_uplink(f"unexpected: {e}", prior_uc, affected) + finally: + with self._lock: + if self._cur and self._cur["state"] in _ACTIVE: + self._cur["state"] = "FAILED_CRITICAL"; self._persist() + + def _rollback_uplink(self, reason, prior_uc, affected): + aid = self._cur["apply_id"] + self._cur["state"] = "ROLLING_BACK"; self._persist() + self._step("ROLLING_BACK", "warn", reason, aid) + try: + snap = self._cur.get("snapshot_dir") + if not snap or not snapshot.verify(snap): + raise RuntimeError("snapshot missing/corrupt") + self._set_uplink_config(prior_uc) # 선택 복원 + snapshot.restore_files(snap, self.net_dir) # 렌더(라우트 포함) 복원 + self._run(["networkctl", "reload"], 15, aid) + for ifc in affected: + self._run(["networkctl", "reconfigure", ifc], 15, aid) + from network import uplink as _uplink + restored_it = self._uplink_intent(_uplink.selected_iface(prior_uc)) + rres = self.verify_fn(restored_it, affected) + if rres["result"] == "fail": + # uplink 롤백은 라우트 제거/복원에 한정 — 파일+reconfigure 성공한 이상 + # re-verify 실패는 운영 단절 X. 저널 warn 후 ROLLED_BACK 유지(F1-robust). + self.journal.event("apply", phase="ROLLING_BACK", + action="uplink_rollback_reverify_warn", result="warn", + apply_id=aid, detail={"checks": rres.get("checks")}) + self._cur["state"] = "ROLLED_BACK"; self._persist() + self._step("ROLLED_BACK", "ok", "", aid) + return {"state": "ROLLED_BACK", "apply_id": aid, "reason": reason} + except Exception as e: # noqa: BLE001 + self._cur["state"] = "FAILED_CRITICAL"; self._persist() + self._step("FAILED_CRITICAL", "fail", str(e), aid) + return {"state": "FAILED_CRITICAL", "apply_id": aid, "reason": f"{reason}; rollback: {e}"} + def recover_on_startup(self, block=False): """server 시작 시: ① country_pending 해소 검사 ② 미완료 apply 복구 (§5 크래시 안전). diff --git a/src/network/net_routes.py b/src/network/net_routes.py index 1bb3e94..72afe95 100644 --- a/src/network/net_routes.py +++ b/src/network/net_routes.py @@ -149,3 +149,49 @@ class NetworkRoutes: return out self.engine.db.update_config("net_config", _mut, default={}) return {"ok": True, "net_config": self._current_config()} + + # ── uplink (Telemetry Uplink — Ethernet 전환) ───────────── + # codex C3: net_routes._uplink_intent 제거 — get_uplink/post_uplink 가 engine._uplink_intent + # (baseline 기준)로 통일. 기존 _uplink_intent(device_config 기준)는 호출자 0 → 제거. + + def get_uplink(self): + """Codex: 프론트가 'no_gateway 옵션 비활성' 판정에 쓸 per-iface gateway_present 를 응답에 포함. + ifaces 단순 리스트 대신 options:[{iface,gateway_present,disabled_reason?}].""" + from network import netmodel as _nm, uplink as _uplink + uc = self.engine.db.get_config("uplink_config") + uc = uc if isinstance(uc, dict) else {} + # codex C3: device_config 드리프트 대신 engine baseline intent 단일 출처 — validate/status 일치. + # review U7: uc 는 1회 읽고 selected_iface() 에 넘김(비원자 이중읽기 차단 유지). + intent = self.engine._uplink_intent(_uplink.selected_iface(uc)) + options = [] + for ifc in _uplink.UPLINK_IFACES: + gw_present = (ifc == _uplink.DEFAULT_IFACE) or _nm.gateway_set(intent.get(ifc) or {}) + opt = {"iface": ifc, "gateway_present": gw_present, + "advisories": _uplink.selection_advisories(ifc, intent)} + if not gw_present: + opt["disabled_reason"] = "no_gateway" + options.append(opt) + return {"uplink_config": {"telemetry_iface": _uplink.selected_iface(uc), + "mode": uc.get("mode", "route")}, + "options": options, + "status": _uplink.status(intent, self.engine.runner)} + + def post_uplink(self, body): + from network import uplink as _uplink + if not isinstance(body, dict): + raise RouteError(400, "request body must be a JSON object") + iface = body.get("telemetry_iface") + if not isinstance(iface, str): + raise RouteError(400, "telemetry_iface must be a string") + # codex C3: device_config 드리프트 대신 engine baseline intent 로 검증 — apply 와 동일 출처. + intent = self.engine._uplink_intent(iface) + errs = _uplink.validate_selection(iface, intent) + if errs: + raise RouteError(400, "; ".join(errs)) + res = self.engine.apply_uplink_async(iface) # Codex #8: async — STARTED 즉시 반환 + if res.get("state") == "COUNTRY_PENDING": # codex C1: reboot-deferred country 보호 + raise RouteError(409, "A Wi-Fi country change is pending a reboot — " + "switch the telemetry uplink after rebooting.") + if res.get("state") == "BUSY": + raise RouteError(409, f"apply in flight: {res.get('apply_id')}") + return {"ok": True, **res} diff --git a/src/network/netmodel.py b/src/network/netmodel.py index ba31a9e..4f13cca 100644 --- a/src/network/netmodel.py +++ b/src/network/netmodel.py @@ -128,7 +128,7 @@ def diff_intents(a, b): new_desc = _profile_descriptor(pb) # password-only change: ssid+security identical but tuples differ → append visible marker if old_desc == new_desc: - new_desc += " (자격증명 변경)" + new_desc += " (credentials changed)" out.append({"field": "wlan0.profiles", "old": old_desc, "new": new_desc}) return out diff --git a/src/network/renderer.py b/src/network/renderer.py index 0c5d20a..52d2c32 100644 --- a/src/network/renderer.py +++ b/src/network/renderer.py @@ -28,6 +28,11 @@ def render_network_file(intent, iface): if sip and gateway_set(c) and c["mode"] == "static" \ and not _in_subnet(sip, c["ip"], c["netmask"]): out += f"\n[Route]\nDestination={sip}/32\nGateway={c['gateway']}\n" + # uplink: 선택 iface 에 클라우드 텔레메트리 /32 host-route 추가 (eth0 lte_server 라우트와 공존) + up = intent.get("_uplink") or {} + if up.get("iface") == iface: + from network import uplink as _uplink + out += _uplink.render_routes(intent, iface) return out def render_wpa_conf(intent): diff --git a/src/network/uplink.py b/src/network/uplink.py new file mode 100644 index 0000000..89a8f9d --- /dev/null +++ b/src/network/uplink.py @@ -0,0 +1,381 @@ +# src/network/uplink.py +"""Telemetry uplink (Ethernet 전환) — dpworldapp 클라우드 텔레메트리(protocol/update/rtcm)를 +선택 인터페이스로 보내는 /32 host-route 생성·검증·상태 (spec 2026-06-25). +dpworldapp 소유 서버 IP는 읽기 전용; 선택은 웹 소유 uplink_config 키.""" +import re +from network.netmodel import _s, _ip_u32, gateway_set +from network.renderer import _in_subnet + +CLOUD_TELEMETRY_KEYS = ( + ("protocol_server_ip", "protocol_server_port"), + ("update_server_ip", "update_server_port"), + ("rtcm_server_ip", "rtcm_server_port"), +) +# UI display labels for the cloud telemetry servers — TIOT=protocol stream, +# Update=firmware update, DGPS=RTCM corrections. Display-only; never affects routing/targets. +CLOUD_TELEMETRY_LABELS = { + "protocol_server_ip": "TIOT", + "update_server_ip": "Update", + "rtcm_server_ip": "DGPS", +} +UPLINK_IFACES = ("wlan0", "eth0", "eth1") +DEFAULT_IFACE = "wlan0" # 라우트 없음 = 현 default route 거동 + + +def _plen_to_mask(plen): + """prefix-length (int 0-32) → dotted-quad netmask 문자열. stdlib만 사용(ipaddress 불필요).""" + n = (0xffffffff << (32 - int(plen))) & 0xffffffff + return "%d.%d.%d.%d" % (n >> 24, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff) + + +def live_subnets(runner, ifaces=UPLINK_IFACES): + """{ifc: (ip, netmask)} — 현재 IPv4 주소가 살아 있는 인터페이스 맵 (DHCP 포함). + 'ip -o -4 addr show dev ' 파싱; inet / 토큰 추출·변환. + fail-safe: ifc별 try/except — rc≠0·no-match·예외 시 해당 ifc 생략, raise 없음. + on-link 탐지 보강에만 사용(렌더·static IP 경로 미영향, U3).""" + out = {} + for ifc in ifaces: + try: + rc, text = runner(["ip", "-o", "-4", "addr", "show", "dev", ifc], 5) + if rc != 0: + continue + m = re.search(r'\binet\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\b', text or "") + if not m: + continue + out[ifc] = (m.group(1), _plen_to_mask(int(m.group(2)))) + except Exception: # noqa: BLE001 — fail-safe: ifc 생략, 절대 raise 안 함 + continue + return out + + +def _telemetry_servers(device_config): + """[(ip, [ip_key, ...]), ...] — deduped cloud telemetry IPs in CLOUD_TELEMETRY_KEYS first-seen + order, each paired with EVERY key that targets it. Empty/invalid IPs (_ip_u32 0/None) excluded. + SINGLE source for telemetry_targets (the IP list) and telemetry_target_labels (joined labels) — + so their IP sets can never drift (fix-the-twin: one filter, not two copies).""" + dc = device_config if isinstance(device_config, dict) else {} + order, by_ip = [], {} + for ip_key, _port_key in CLOUD_TELEMETRY_KEYS: + ip = _s(dc.get(ip_key)) + if ip and _ip_u32(ip) not in (0, None): + if ip not in by_ip: + by_ip[ip] = [] + order.append(ip) + by_ip[ip].append(ip_key) + return [(ip, by_ip[ip]) for ip in order] + + +def telemetry_targets(device_config): + """클라우드 텔레메트리 서버 IP(읽기 전용, 빈/비파싱 제외·중복 제거·순서 보존). + 로컬 opc_ua/modbus·lte_server 는 미포함(CLOUD_TELEMETRY_KEYS 한정). _telemetry_servers 단일 출처.""" + return [ip for ip, _keys in _telemetry_servers(device_config)] + + +def telemetry_target_labels(device_config): + """{ip: label}. 같은 IP에 여러 서버(예: protocol=TIOT, rtcm=DGPS — 같은 호스트·다른 포트)면 라벨 join + ('TIOT / DGPS') — /32 host-route 는 per-IP(포트 무관)라 한 라우트가 둘 다 커버 → 둘 다 표기. 표시 전용. + _telemetry_servers 단일 출처라 telemetry_targets 와 키 집합이 구조적으로 동일(drift 불가).""" + out = {} + for ip, keys in _telemetry_servers(device_config): + labels = [] + for k in keys: + lbl = CLOUD_TELEMETRY_LABELS.get(k, k) + if lbl not in labels: + labels.append(lbl) + out[ip] = " / ".join(labels) + return out + + +def selected_iface(uplink_config): + """uplink_config.telemetry_iface ∈ UPLINK_IFACES. 그 외/부재 → DEFAULT_IFACE.""" + uc = uplink_config if isinstance(uplink_config, dict) else {} + ifc = _s(uc.get("telemetry_iface")) + return ifc if ifc in UPLINK_IFACES else DEFAULT_IFACE + + +def attach(intent, device_config, uplink_config, live=None): + """intent['_uplink'] = {'iface','targets','labels','live_subnets'} 주입(in-memory). + persist(network_config.json)엔 미기록(§5.2). 렌더러/verifier/watchdog 공용 단일 출처. + device-network apply 도 _write_renders 전 호출(라우트 wipe 방지). + live: live_subnets() 결과({ifc:(ip,mask)}) — None/미지정 시 {} (기존 호출자 무영향, U3).""" + intent["_uplink"] = {"iface": selected_iface(uplink_config), + "targets": telemetry_targets(device_config), + "labels": telemetry_target_labels(device_config), + "live_subnets": live or {}} + + +def validate_selection(iface, intent): + """선택 검증 — errors 목록. iface enum + (비-wlan0면) static-gateway 존재. + intent 는 device 필드가 채워진 상태(net_dir baseline 또는 device_config 유도).""" + if iface not in UPLINK_IFACES: + return [f"telemetry_iface must be one of {UPLINK_IFACES}"] + if iface != DEFAULT_IFACE: + c = intent.get(iface) or {} + if not gateway_set(c): + return ["no_gateway"] + return [] + + +def _on_link_ifaces(ip, intent): + """All UPLINK_IFACES whose subnet contains `ip` (UPLINK_IFACES order). + Primary source: CONFIGURED ip+netmask from the intent (static ifaces). + Live fallback (U3): when configured ip/netmask is empty (DHCP iface), falls back to + intent['_uplink']['live_subnets'][ifc] supplied by callers with a runner (attach live= kwarg). + Absent live_subnets → today's behavior (DHCP iface never matches). Fail-safe: a failed + live_subnets read degrades to {} and never installs a wrong /32. An iface with empty + ip/netmask (from both sources) never matches (empty netmask would read as /0 = match-all). + length>1 result means overlapping subnets (misconfiguration — selection_advisories surfaces it).""" + live = (intent.get("_uplink") or {}).get("live_subnets") or {} + out = [] + for ifc in UPLINK_IFACES: + c = intent.get(ifc) or {} + cip, mask = _s(c.get("ip")), _s(c.get("netmask")) + if not (cip and mask): + # DHCP iface(또는 미설정): live 주소로 폴백 — 부재 시 ""/"" → 여전히 미매칭(fail-safe) + lr = live.get(ifc) + if lr: + cip, mask = lr + if cip and mask and _in_subnet(ip, cip, mask): + out.append(ifc) + return out + + +def _on_link_iface(ip, intent): + """First interface `ip` is on-link via (configured), or None. Deterministic by UPLINK_IFACES + order. Configured, not live route proof (see _on_link_ifaces).""" + ifaces = _on_link_ifaces(ip, intent) + return ifaces[0] if ifaces else None + + +def selection_advisories(iface, intent): + """Soft, English pre-apply advisories for selecting `iface` (the candidate). PURE — no runner, + no I/O. Reads the iface-independent targets/labels from intent['_uplink'] (attach()-populated; + same source as plan_routes/status) and per-iface subnets from intent[]. Returns [] when + '_uplink' is absent. Rules (spec 2026-06-26 §5.2), in emitted order: 0 overlap-subnet, + 1 local-via-other-iface, 2 all-local (non-wlan0), 3 wlan0 revert.""" + up = intent.get("_uplink") + if not isinstance(up, dict): + return [] # spec §5.2: absent _uplink → [] for EVERY iface (incl. wlan0 — no targets to revert) + targets = up.get("targets") or [] + labels = up.get("labels") or {} + out = [] + # rule 0: overlapping-subnet misconfiguration (a target on-link via >1 iface) + for ip in targets: + ifs = _on_link_ifaces(ip, intent) + if len(ifs) > 1: + lbl = labels.get(ip) or ip + out.append("%s %s matches more than one interface subnet (%s) — check the interface " + "configuration; the displayed interface is the first match." + % (lbl, ip, ", ".join(ifs))) + # rule 1: target on-link via a DIFFERENT interface than the candidate + for ip in targets: + y = _on_link_iface(ip, intent) + if y is not None and y != iface: + lbl = labels.get(ip) or ip + out.append("%s %s stays on %s (local LAN) — selecting %s won't move it." + % (lbl, ip, y, iface)) + # rules 2/3 (mutually exclusive on iface) + if iface != DEFAULT_IFACE: + steerable = [ip for ip in targets if _on_link_iface(ip, intent) is None] + if targets and not steerable: + out.append("All telemetry servers are on local LANs; selecting %s installs no " + "host-routes (this may be correct)." % iface) + else: + out.append("Reverts to the system default route and removes the host-routes.") + return out + + +def _on_link_any(ip, intent): + """True if `ip` is on-link via ANY configured interface — directly reachable without a gateway. + Such a target MUST NOT be host-routed via a foreign gateway: doing so black-holes a + locally-reachable host (e.g. selecting eth0 for a 192.168.55.x server on-link via eth1) and + breaks asymmetric routing to/from it (management lockout, 2026-06-26).""" + return bool(_on_link_ifaces(ip, intent)) + + +def plan_routes(intent): + """(routes, skips). intent['_uplink']={iface,targets} 기준. + iface==DEFAULT_IFACE → ([], []). iface 게이트웨이 없음 → ([], ['no_gateway']). + 타겟이 ★어느 인터페이스든 on-link(서브넷 내) → 그 타겟 skip('local:') — 선택 iface 게이트웨이로 + /32 깔면 on-link 호스트 블랙홀(타 iface 로컬 서버 + 관리 락아웃, 2026-06-26). 그 외 타겟별 /32 route dict. + static-gateway 한정 — Gateway=_dhcp4 미사용(systemd 버전 의존 회피).""" + up = intent.get("_uplink") or {} + iface = up.get("iface") or DEFAULT_IFACE + targets = up.get("targets") or [] + if iface == DEFAULT_IFACE: + return [], [] + c = intent.get(iface) or {} + if not gateway_set(c): + return [], ["no_gateway"] + gw = _s(c.get("gateway")) + routes, skips = [], [] + for ip in targets: + if _on_link_any(ip, intent): # on-link via ANY iface → no /32 (would black-hole; see _on_link_any) + skips.append(f"local:{ip}") + continue + routes.append({"dest": ip, "gateway": gw}) + return routes, skips + + +def render_routes(intent, iface): + """선택 iface .network 에 붙일 [Route] 텍스트. render_network_file 이 iface==_uplink.iface 일 때만 호출.""" + routes, _skips = plan_routes(intent) + out = "" + for r in routes: + out += f"\n[Route]\nDestination={r['dest']}/32\nGateway={r['gateway']}\n" + return out + + +def _routes_show(runner): + rc, out = runner(["ip", "route", "show"], 5) + return out if rc == 0 else "" + + +def _installed_route(routes, ip): + """ip route show 에서 host 라인 파싱 → {'dev','gateway'} 또는 None (Codex #6: dev+gateway). + /32 host 는 suffix 없이 ' via dev ' 로 표기.""" + for ln in routes.splitlines(): + toks = ln.split() + if not toks or toks[0] != ip: + continue + dev = toks[toks.index("dev") + 1] if "dev" in toks else "" + gw = toks[toks.index("via") + 1] if "via" in toks else "" + return {"dev": dev, "gateway": gw} + return None + + +def stale_routes(targets, routes): + """클라우드 타겟(목록)이 eth0/eth1 에 /32 로 잔존하면 [{ip,dev}] 반환 (Codex #5 — wlan0 선택 시 비어야 ok).""" + out = [] + for ip in targets: + r = _installed_route(routes, ip) + if r and r["dev"] in ("eth0", "eth1"): + out.append({"ip": ip, "dev": r["dev"]}) + return out + + +def _addr_to_iface(intent): + """intent per-iface ip → iface 이름. egress local IP 를 iface 로 매핑(정직 표기).""" + m = {} + for ifc in UPLINK_IFACES: + ip = _s((intent.get(ifc) or {}).get("ip")) + if ip: + m[ip] = ifc + return m + + +def observe_egress(targets, runner): + """ss -tn(없으면 /proc/net/tcp 폴백)로 각 target 으로의 ESTAB 소켓 local IP 관측. {target_ip: local_ip|None}. + dpworldapp 실제 송신 경로의 ground truth — '적용됨'을 'working' 으로 위장 금지.""" + res = {ip: None for ip in targets} + rc, out = runner(["ss", "-tn"], 5) + if rc != 0: + return _egress_from_proc(targets, runner, res) + for ln in out.splitlines(): + toks = ln.split() + if len(toks) < 5 or toks[0] != "ESTAB": + continue + peer, local = toks[-1], toks[-2] + if ":" in peer and ":" in local: + pip = peer.rsplit(":", 1)[0] + if pip in res: + res[pip] = local.rsplit(":", 1)[0] + return res + + +def _egress_from_proc(targets, runner, res): + """ss 부재(busybox) 폴백 — /proc/net/tcp hex 파싱. 실패 시 모두 None 유지(거짓 green 금지).""" + rc, out = runner(["cat", "/proc/net/tcp"], 5) + if rc != 0: + return res + want = {_ip_to_hex_le(ip): ip for ip in targets} + for ln in out.splitlines()[1:]: + f = ln.split() + if len(f) < 4 or f[3] != "01": # 01 = ESTABLISHED + continue + rem_hex = f[2].split(":")[0] + if rem_hex in want: + loc_hex = f[1].split(":")[0] + res[want[rem_hex]] = _hex_le_to_ip(loc_hex) + return res + + +def _ip_to_hex_le(ip): + parts = ip.split(".") + if len(parts) != 4: + return "" + return "".join(f"{int(p):02X}" for p in reversed(parts)) + + +def _hex_le_to_ip(h): + try: + b = [int(h[i:i + 2], 16) for i in (6, 4, 2, 0)] + return ".".join(str(x) for x in b) + except (ValueError, IndexError): + return "" + + +def reachability(targets, iface, gateway, runner): + """비침습 도달성 — ip route show 로 via dev 설치 확인(dev+gateway 둘 다 — Codex #6).""" + routes = _routes_show(runner) + out = [] + for ip in targets: + r = _installed_route(routes, ip) + out.append({"ip": ip, + "route_installed": bool(r) and r["dev"] == iface and r["gateway"] == gateway}) + return out + + +def status(intent, runner): + """3단 정직 상태. {iface, per_target:[{ip,label,on_link_iface,route_installed,local,egress_iface}], rollup}. + label = 서버 표시명(TIOT/Update/DGPS, 표시 전용). rollup ∈ {unset, pending, egress, unreachable, stale} + (언어중립 코드 — 프론트 영문 매핑). 'working' 절대 주장 X. + 라우트 판정은 dev+gateway 둘 다(Codex #6). wlan0 선택 시 eth0/eth1 stale /32 잔존 검사(Codex #5). + 선택 iface 서브넷 내 타겟(local)은 /32 불요(on-link 직접 도달) → route_installed 거짓표시 금지(Codex).""" + up = intent.get("_uplink") or {} + iface = up.get("iface") or DEFAULT_IFACE + targets = up.get("targets") or [] + labels = up.get("labels") or {} + routes = _routes_show(runner) + egress = observe_egress(targets, runner) + a2i = _addr_to_iface(intent) + gw = _s((intent.get(iface) or {}).get("gateway")) + # local targets (in the selected iface subnet) need NO /32 route — on-link direct. Use plan_routes' + # classification so status agrees with what apply installs (Codex: don't show "unreachable/missing" + # for a target deliberately skipped as on-link-local). + local_ips = {s.split(":", 1)[1] for s in plan_routes(intent)[1] if s.startswith("local:")} + per = [] + for ip in targets: + if ip in local_ips: + installed, is_local = True, True # on-link, no host-route needed (direct) + elif iface == DEFAULT_IFACE: + installed, is_local = False, False + else: + r = _installed_route(routes, ip) + installed, is_local = bool(r) and r["dev"] == iface and r["gateway"] == gw, False + egr = egress.get(ip) + per.append({"ip": ip, "label": labels.get(ip), "on_link_iface": _on_link_iface(ip, intent), + "route_installed": installed, "local": is_local, + "egress_iface": a2i.get(egr) if egr else None}) + stale = stale_routes(targets, routes) if iface == DEFAULT_IFACE else [] + return {"iface": iface, "per_target": per, "rollup": _rollup(iface, per, stale)} + + +def _rollup(iface, per, stale): + """언어중립 status 코드(프론트가 영문 표시로 매핑) — 영문 UI에 한국어 누출 방지. + honesty: 'working' 류 추론 green 없음. egress=실제 송신 관측만 confirmed.""" + if iface == DEFAULT_IFACE: + return "stale" if stale else "unset" + if not per: + return "unset" + if any(p["egress_iface"] == iface for p in per): + # Genuine unreachable: non-local target with no route AND no observed egress — don't mask + genuinely_unreachable = any( + not p["local"] and not p["route_installed"] and p["egress_iface"] is None + for p in per + ) + if not genuinely_unreachable: + return "egress" + if all(p["route_installed"] for p in per): + return "pending" + return "unreachable" diff --git a/src/network/validator.py b/src/network/validator.py index ea133e0..8ac944f 100644 --- a/src/network/validator.py +++ b/src/network/validator.py @@ -43,61 +43,61 @@ def validate(intent): w = intent["wlan0"] profs = w.get("profiles", []) if len(profs) > MAX_PROFILES: - errs.append(f"wlan0.profiles: 최대 {MAX_PROFILES}개 (현재 {len(profs)})") + errs.append(f"wlan0.profiles: max {MAX_PROFILES} (currently {len(profs)})") # gap 검출 (§5.2): 첫 빈 SSID 이후의 비어있지 않은 프로파일은 렌더에서 침묵 탈락 → 거부. # trailing 빈 슬롯(UI 빈 행)은 허용. first_empty = next((i for i, p in enumerate(profs) if not p["ssid"]), None) if first_empty is not None: for j in range(first_empty + 1, len(profs)): if profs[j]["ssid"]: - errs.append(f"wlan0.profiles[{j}]: gap 금지 — 빈 슬롯[{first_empty}] 뒤 프로파일은 적용되지 않음 (압축 저장 필요)") + errs.append(f"wlan0.profiles[{j}]: no gap allowed — profiles after empty slot[{first_empty}] are not applied (must be stored compacted)") for i, p in enumerate(profs): tag = f"wlan0.profiles[{i}]" if not p["ssid"]: continue # 빈 슬롯 자체는 gap 검사에서 처리 ssid_bytes = len(p["ssid"].encode("utf-8")) if ssid_bytes > MAX_SSID: - errs.append(f"{tag}: SSID {ssid_bytes}바이트 > {MAX_SSID} (펌웨어 절단)") + errs.append(f"{tag}: SSID {ssid_bytes} bytes > {MAX_SSID} (firmware truncates)") # C2: SSID 에 ", \, 또는 제어문자 금지 (wpa 렌더 escaping 없음 + injection 방지) if _has_forbidden_char(p["ssid"]): - errs.append(f"{tag}: SSID 에 \", \\\\, 또는 제어문자 금지 (wpa 렌더 escaping 없음)") + errs.append(f"{tag}: SSID must not contain \", \\\\, or control characters (wpa render has no escaping)") if p["security"] not in ALLOWED_SECURITY: - errs.append(f"{tag}: security={p['security']!r} 불가 — {ALLOWED_SECURITY} 만 (그 외는 펌웨어가 프로파일 삭제)") + errs.append(f"{tag}: security={p['security']!r} not allowed — only {ALLOWED_SECURITY} (anything else makes the firmware delete the profile)") elif p["security"] == "none": if p["password"]: - errs.append(f"{tag}: security=none 이면 password 는 빈 값 강제") + errs.append(f"{tag}: security=none requires password to be empty") else: pw_bytes = len(p["password"].encode("utf-8")) if not (PSK_MIN <= pw_bytes <= PSK_MAX): - errs.append(f"{tag}: password {pw_bytes}바이트 — {PSK_MIN}-{PSK_MAX}바이트 필요") + errs.append(f"{tag}: password {pw_bytes} bytes — {PSK_MIN}-{PSK_MAX} bytes required") # C2: password 에 ", \, 또는 제어문자 금지 if _has_forbidden_char(p["password"]): - errs.append(f"{tag}: password 에 \", \\\\, 또는 제어문자 금지 (wpa 렌더 escaping 없음)") + errs.append(f"{tag}: password must not contain \", \\\\, or control characters (wpa render has no escaping)") cc = w.get("country_code", "") if cc and not (len(cc) == 2 and cc.isalpha() and cc.isupper()): - errs.append(f"wlan0.country_code={cc!r}: 2자리 대문자 알파벳") + errs.append(f"wlan0.country_code={cc!r}: must be 2 uppercase letters") for iface in ("wlan0", "eth0", "eth1"): c = intent[iface] if c["mode"] != "static": continue if not _ip_ok(c["ip"]): - errs.append(f"{iface}: static 인데 IP 없음/형식 오류 ({c['ip']!r})") + errs.append(f"{iface}: static mode but IP missing/malformed ({c['ip']!r})") continue # C1: netmask must be contiguous (also subsumes the format check — a # contiguous netmask is necessarily a valid IPv4 address; v1.11.10 Fix 38 # removed the unreachable _ip_ok(netmask) follow-up block). if not _netmask_contiguous(c["netmask"]): - errs.append(f"{iface}: netmask 비연속/형식 오류 ({c['netmask']!r})") + errs.append(f"{iface}: netmask non-contiguous/malformed ({c['netmask']!r})") continue gw = c.get("gateway", "") if gw: # 빈 gateway 는 허용 — [Route] 생략 (§5.2/§12-2) if not _ip_ok(gw): - errs.append(f"{iface}: gateway 형식 오류 ({gw!r})") + errs.append(f"{iface}: gateway malformed ({gw!r})") else: try: net = ipaddress.IPv4Network(f"{c['ip']}/{c['netmask']}", strict=False) if ipaddress.IPv4Address(gw) not in net: - warns.append(f"{iface}: gateway {gw} 가 subnet {net} 밖 — 확인 필요") + warns.append(f"{iface}: gateway {gw} is outside subnet {net} — please verify") except ValueError: pass # I3: port rules — eth0.server_port, eth1.opc_ua_server_port, eth1.modbus_server_port @@ -109,7 +109,7 @@ def validate(intent): for iface, field in port_checks: val = intent[iface].get(field, "") if not _port_ok(val): - errs.append(f"{iface}.{field}: 포트 범위 오류 ({val!r}) — 빈 값 또는 1-65535 정수") + errs.append(f"{iface}.{field}: port out of range ({val!r}) — empty or integer 1-65535") # v1.11.8 D: server_ip / dns IPv4 format reject (이전엔 비교만 하고 형식 미검증). # 빈 값 = clear(허용). netmodel._ip_u32 공유 검증기 사용 — 0=빈값, None=형식오류 # (device_config 경로 _is_valid_ip 와 동일한 leading-zero strict 규칙). @@ -125,7 +125,7 @@ def validate(intent): if val in ("", None): continue # 빈 값 = clear (허용) if netmodel._ip_u32(val) is None: - errs.append(f"{iface}.{field}: IP 형식 오류 ({val!r})") + errs.append(f"{iface}.{field}: IP malformed ({val!r})") # C1: subnet-overlap loop — wrap each pair in try/except ValueError to never throw statics = [(i, intent[i]) for i in ("wlan0", "eth0", "eth1") if intent[i]["mode"] == "static" and _ip_ok(intent[i]["ip"]) and _netmask_contiguous(intent[i].get("netmask", ""))] @@ -135,7 +135,7 @@ def validate(intent): na = ipaddress.IPv4Network(f"{statics[a][1]['ip']}/{statics[a][1]['netmask']}", strict=False) nb = ipaddress.IPv4Network(f"{statics[b][1]['ip']}/{statics[b][1]['netmask']}", strict=False) if na.overlaps(nb): - warns.append(f"{statics[a][0]}/{statics[b][0]}: 서브넷 중복 ({na} ↔ {nb})") + warns.append(f"{statics[a][0]}/{statics[b][0]}: subnet overlap ({na} ↔ {nb})") except ValueError: continue return errs, warns diff --git a/src/network/verifier.py b/src/network/verifier.py index 5cc9383..d31362c 100644 --- a/src/network/verifier.py +++ b/src/network/verifier.py @@ -38,17 +38,17 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc routes_rc, routes = runner(["ip", "route", "show"], 5) if not ip_ok: # I8: ip 실패 = 라이브 상태 미상 → 명시적 fail + per-iface 라이브 검증 생략 - add("verifier.ip", "fail", "ip -br addr show 실패 — 라이브 상태 미상 (per-iface 검증 생략)") + add("verifier.ip", "fail", "ip -br addr show failed — live state unknown (per-iface checks skipped)") return {"result": worst, "checks": checks} if routes_rc != 0: - add("verifier.ip_route", "fail", "ip route show 실패 — 라우트 검증 불가") + add("verifier.ip_route", "fail", "ip route show failed — cannot verify routes") routes = "" for iface in changed_ifaces: if iface == "country": # reboot-deferred 변경엔 라이브 검증 없음 continue c = intent.get(iface, {}) if not exists(f"/sys/class/net/{iface}"): - add(f"{iface}.exists", "fail", "netdev 부재"); continue + add(f"{iface}.exists", "fail", "netdev missing"); continue add(f"{iface}.exists", "ok") # DEF-1: wlan0 with effective profiles — wpa_state wait loop is the gate regardless of # momentary carrier state (wrong password makes wpa drop → carrier DOWN before wpa settles). @@ -64,7 +64,7 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc # carrier check is deferred: emitted ok/warn AFTER wpa result is known. pass # fall through (do NOT continue, do NOT add carrier check yet) else: - add(f"{iface}.carrier", "warn", "carrier 없음 — config staged (§5.3)") + add(f"{iface}.carrier", "warn", "no carrier — config staged (§5.3)") continue # 라이브 검증 생략 else: add(f"{iface}.carrier", "ok", st) @@ -94,13 +94,13 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc add(f"{iface}.address", "ok", c["ip"]) else: add(f"{iface}.address", "fail", - f"기대 {c['ip']} / 실제 {live.get(iface, {}).get('addrs')} (settle {settle_s}s)") + f"expected {c['ip']} / actual {live.get(iface, {}).get('addrs')} (settle {settle_s}s)") if gw: if route_ok: add(f"{iface}.route", "ok", gw) else: add(f"{iface}.route", "fail", - f"default via {gw} dev {iface} 부재 (settle {settle_s}s)") + f"default via {gw} dev {iface} missing (settle {settle_s}s)") if wlan_gated: deadline = clock() + wpa_wait_s state = "" @@ -127,7 +127,7 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc add(f"{iface}.address", "ok", c["ip"]) else: add(f"{iface}.address", "fail", - f"기대 {c['ip']} / 실제 {live.get(iface, {}).get('addrs')} (post-wpa)") + f"expected {c['ip']} / actual {live.get(iface, {}).get('addrs')} (post-wpa)") # Fix #14: when carrier was initially down, the route was never # verified (the carrier-up settle branch above was skipped). # Re-fetch routes and check the default route (mirror ~98-103). @@ -140,11 +140,11 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc add(f"{iface}.route", "ok", gw) else: add(f"{iface}.route", "fail", - f"default via {gw} dev {iface} 부재 (post-wpa)") + f"default via {gw} dev {iface} missing (post-wpa)") else: # deferred carrier check: wpa failed → emit warn for the carrier state we saw if not carrier_up: - add(f"{iface}.carrier", "warn", "association 진행 중 — wpa_state 대기") + add(f"{iface}.carrier", "warn", "association in progress — waiting for wpa_state") add("wlan0.wpa", "fail", f"wpa_state={state or 'unknown'} (wait {wpa_wait_s}s)") gw = c.get("gateway", "") # minor: ping skip 은 per-iface — 다른 iface 의 fail 이 이 iface 의 ping 을 막지 않는다 @@ -155,7 +155,27 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc rc, _o = runner(["ping", "-c", "1", "-W", "2", gw], 5) if rc == 0: ok = True; break - add(f"{iface}.gw_ping", "ok" if ok else "warn", gw if ok else f"{gw} 무응답 (WARN — AP측 원인 가능)") + add(f"{iface}.gw_ping", "ok" if ok else "warn", gw if ok else f"{gw} no response (WARN — may be an AP-side cause)") + # uplink: 선택 iface(비-wlan0)면 기대 /32 라우트 설치를 검증 (changed_ifaces 포함 여부 무관) + up = intent.get("_uplink") or {} + up_iface = up.get("iface") + if up_iface: + from network import uplink as _uplink + rc_u, routes_u = runner(["ip", "route", "show"], 5) + rtxt = routes_u if rc_u == 0 else routes + if up_iface != "wlan0": + for r in _uplink.plan_routes(intent)[0]: # 기대 라우트(서브넷-로컬 제외) + got = _uplink._installed_route(rtxt, r["dest"]) + if got and got["dev"] == up_iface and got["gateway"] == r["gateway"]: + add(f"uplink.{r['dest']}", "ok", f"{up_iface} via {r['gateway']}") + else: # dev+gateway 둘 다 일치해야 ok (Codex #6) + add(f"uplink.{r['dest']}", "fail", + f"route {r['dest']} via {r['gateway']} dev {up_iface} not installed (actual={got})") + else: + # wlan0 복귀: 클라우드 타겟이 eth0/eth1 에 stale /32 로 잔존하면 fail (Codex #5) + for s in _uplink.stale_routes(up.get("targets") or [], rtxt): + add(f"uplink.stale.{s['ip']}", "fail", + f"stale route {s['ip']} dev {s['dev']} — wlan0 selected but Ethernet route still present") # v1.9: split-apply 시 country 는 deferred — 라이브 regdomain 이 절대 NEW 로 새지 않았음을 단언. # ground truth = iw reg get (wpa_cli get country 는 렌더값일 뿐, firmware 주석대로 거짓양성). # positive 누출(live==NEW≠OLD)만 fail→롤백; 판독불가/모호는 warn(정상 apply 를 롤백시키지 않음). @@ -169,9 +189,9 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc if s.startswith("country ") and len(s) >= 10 and s[8:10].isalpha(): live_cc = s[8:10].upper(); break if rc != 0 or not live_cc: - add("country.deferred", "warn", "regdomain 판독 불가 — deferred 미확인 (non-fatal)") + add("country.deferred", "warn", "regdomain unreadable — deferred unconfirmed (non-fatal)") elif live_cc == new_cc and new_cc != old_cc: - add("country.deferred", "fail", f"deferred country 가 라이브로 누출: {live_cc} (기대 {old_cc})") + add("country.deferred", "fail", f"deferred country leaked live: {live_cc} (expected {old_cc})") else: - add("country.deferred", "ok", f"regdomain {live_cc} 유지 (NEW {new_cc} 는 reboot 후)") + add("country.deferred", "ok", f"regdomain {live_cc} retained (NEW {new_cc} applies after reboot)") return {"result": worst, "checks": checks} diff --git a/src/network/watchdog.py b/src/network/watchdog.py index 78dcdd0..2a53b82 100644 --- a/src/network/watchdog.py +++ b/src/network/watchdog.py @@ -105,13 +105,35 @@ class NetworkWatchdog: detail={"note": "wpa_cli 무응답 — UNKNOWN 처리, 복구 안 함 (C3)"}) def _collect_checks(self): - """name → bool(healthy). 이름 = wlan_module/wpa_state/addr_route/eth0/eth1. + """name → bool(healthy). 이름 = wlan_module/wpa_state/addr_route/eth0/eth1/uplink_routes. 테스트에서 통째 주입.""" + out = {} + # uplink_routes: 선택 iface 감시 — network_config.json 비의존(Codex #9). + from network import netmodel, uplink as _uplink + try: + uc = self.db.get_config("uplink_config") + dev = self.db.get_config("device_config") or {} + except Exception: # noqa: BLE001 + uc, dev = None, {} + up_iface = _uplink.selected_iface(uc if isinstance(uc, dict) else {}) + rc_u, routes_u = self.runner(["ip", "route", "show"], 5) + if rc_u == 0: # rc!=0 = UNKNOWN → 키 생략(거짓 unhealthy 금지, C3 패턴) + if up_iface != "wlan0": + it2 = netmodel.intent_from_device(dev) + _uplink.attach(it2, dev, {"telemetry_iface": up_iface}, + live=_uplink.live_subnets(self.runner)) # codex C4: U3 4번째 호출 site — DHCP on-link skip + plan, _sk = _uplink.plan_routes(it2) + if plan: + out["uplink_routes"] = all( + (got := _uplink._installed_route(routes_u, r["dest"])) + and got["dev"] == up_iface and got["gateway"] == r["gateway"] + for r in plan) + else: # wlan0 선택: eth0/eth1 stale /32 가 없어야 healthy (Codex #5) + out["uplink_routes"] = not _uplink.stale_routes( + _uplink.telemetry_targets(dev), routes_u) it = self._intent() if it is None: - return {} - out = {} - from network import netmodel + return out # ★ {} 아님 — uplink 체크 보존(early-return 앞에서 채움) wifi_configured = bool(netmodel.effective_profiles(it)) if wifi_configured: out["wlan_module"] = os.path.isdir("/sys/module/wlan") and \ @@ -192,6 +214,32 @@ class NetworkWatchdog: return rc == 0 def _do_recover(self, name, ladder_idx): + if name == "uplink_routes": + from network import uplink as _uplink + # review U4: country 가 reboot-deferred 보류 중이면 uplink self-heal 건너뜀 — apply_uplink + # 가 wpa conf 를 NEW country 로 재렌더 + apply.service/wpa reconfigure 로 보류된 regdomain + # 을 라이브 누출하는 것 차단(정상 래더의 country_pending 억제와 대칭). + if self.engine.country_pending(): + self.journal.event("watchdog", phase="recover", action="skip_apply_uplink", + result="warn", detail={"check": name, "reason": "country_pending (§8)"}) + return + uc = self.db.get_config("uplink_config") + up_iface = _uplink.selected_iface(uc if isinstance(uc, dict) else {}) + try: + res = self.engine.apply_uplink(up_iface) + state = (res or {}).get("state", "") + if state == "COMMITTED": + result = "ok" + elif state in ("ROLLED_BACK", "BUSY"): + result = "warn" + else: # FAILED_CRITICAL, ABORTED, FAILED_VALIDATION, UNKNOWN + result = "fail" + self.journal.event("watchdog", phase="recover", action="apply_uplink", + result=result, detail={"iface": up_iface, "state": state}) + except Exception as e: # noqa: BLE001 — 복구 실패가 watchdog thread 죽이면 안 됨 + self.journal.event("watchdog", phase="recover", action="apply_uplink", + result="fail", detail={"error": str(e)}) + return ladder = _ladder_for(name) step = ladder[min(ladder_idx, len(ladder) - 1)] for argv in step: diff --git a/src/server.py b/src/server.py index 60362a0..01dd2fb 100644 --- a/src/server.py +++ b/src/server.py @@ -80,7 +80,10 @@ from firmware.fw_routes import FirmwareRoutes, RouteError # Configuration HOST = os.environ.get("HOST", "0.0.0.0") -PORT = int(os.environ.get("PORT", "8080")) +# v1.12.0: 기본 포트 8080→9090 으로 통일. 설정기는 PC·디바이스 어디서든 :9090 이다 +# (디바이스는 systemd 유닛이 PORT=9090 지정 — 동일값이라 dev/prod 포트가 갈리지 않음). +# 디바이스의 :8080 은 별개의 레거시 Java app-runner 이며 이 앱과 무관하다. +PORT = int(os.environ.get("PORT", "9090")) STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") # v1.5.4: MIME prefixes eligible for gzip compression. @@ -493,6 +496,8 @@ class ConfigHandler(BaseHTTPRequestHandler): elif path == "/api/network/config": # #2: watchdog kill-switch — 현재 net_config (효과적 기본값 merge) 조회 self._net_json(lambda: _NET.get_config()) + elif path == "/api/network/uplink": + self._net_json(lambda: _NET.get_uplink()) # AP: status route elif path == "/api/network/ap/status": self._ap_json(lambda: _AP.status()) @@ -680,6 +685,14 @@ class ConfigHandler(BaseHTTPRequestHandler): {"ok": False, "error": "request body must be a JSON object"}, 400) return self._net_json(lambda: _NET.config(data)) + elif path == "/api/network/uplink": + data = self._read_request_body() + if data is None: + return + if not isinstance(data, dict): + self._send_json_response({"ok": False, "error": "request body must be a JSON object"}, 400) + return + self._net_json(lambda: _NET.post_uplink(data)) # AP: config + apply routes elif path == "/api/network/ap/config": data = self._read_request_body() diff --git a/src/static/css/style.css b/src/static/css/style.css index ce8a898..09277d5 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -1704,6 +1704,13 @@ button.dash-issue__action--link:hover { text-decoration: underline; } .dash-row__lbl { color: var(--text-secondary, #94A3B8); min-width: 96px; } .dash-row__val { color: var(--text-primary, #E2E8F0); font-family: 'JetBrains Mono', monospace; } .dash-row__val--dim { color: var(--text-muted, #64748B); font-size: 12px; } +.ap-client-table { + margin-top: .75rem; +} +.ap-client-table__mono { + font-family: 'JetBrains Mono', monospace; + overflow-wrap: anywhere; +} .dash-row--section { color: var(--text-muted, #888); font-size: 0.85em; @@ -1887,6 +1894,7 @@ button.dash-issue__action--link:hover { text-decoration: underline; } .dash-status-chip--ok { background: var(--success-bg); color: var(--success); } .dash-status-chip--warn { background: var(--warning-bg); color: var(--warning); } .dash-status-chip--error { background: var(--error-bg); color: var(--error); } +.dash-status-chip--na { background: rgba(100,116,139,.12); color: var(--text-muted); } /* ─── Dashboard V2 — Hardware Modules card (v1.11.0) ──────── */ .hw-module + .hw-module { margin-top: var(--space-md); padding-top: var(--space-md); border-top: 1px solid var(--border-default); } @@ -3031,3 +3039,65 @@ button .icon, a .icon, label .icon { pointer-events: none; } .pending-unapplied__desc { color: var(--text-muted, #94A3B8); font-size: var(--font-size-xs, 12px); margin: 0 0 0.4rem; } .pending-unapplied__list { margin: 0 0 0.5rem; padding-left: 1.1rem; } .pending-unapplied__item { font-family: var(--font-mono); font-size: var(--font-size-xs, 12px); } + +/* ═══════════════════════════════════════════════════════════════ + Telemetry Uplink page (uplink.js — selector + honest status) + Route cloud telemetry/RTCM/update over the operator-selected interface. + Tokens only (no new colors). Status colors come from var(--success/warning/danger). + ═══════════════════════════════════════════════════════════════ */ +.uplink-card { + border: 1px solid var(--border, rgba(148,163,184,.25)); + border-radius: 8px; + padding: 1rem 1.1rem; + margin-bottom: 1rem; + background: var(--bg-card, transparent); +} +.uplink-card__title { + margin: 0 0 0.6rem; + font-size: var(--font-size-md, 14px); + font-weight: 600; + color: var(--text-primary, #E2E8F0); +} +.uplink-options { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; } +.uplink-opt { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.6rem; + border: 1px solid var(--border, rgba(148,163,184,.25)); + border-radius: 6px; + cursor: pointer; +} +.uplink-opt:hover { background: var(--bg-card-hover, rgba(148,163,184,.08)); } +.uplink-opt--disabled { cursor: not-allowed; } +/* Dim only the option label — keep the reason note/hint legible (AA). The note + ("set the gateway first") is the actionable text on a disabled row, so it must + not be dimmed below readable contrast. */ +.uplink-opt--disabled .uplink-opt__label { opacity: 0.55; } +.uplink-opt--disabled:hover { background: transparent; } +.uplink-opt:focus-within { border-color: var(--accent, #6366F1); box-shadow: 0 0 0 2px rgba(99,102,241,.25); } +.uplink-fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } +.uplink-opt__label { flex: 1; font-size: var(--font-size-sm, 13px); } +.uplink-opt__hint { font-size: var(--font-size-xs, 12px); color: var(--text-muted, #94A3B8); } +.uplink-opt__hint--warn { color: var(--warning, #F59E0B); } +.uplink-opt__note { display: block; font-size: var(--font-size-xs, 12px); color: var(--text-secondary, #CBD5E1); margin-top: 0.25rem; } + +/* Honest status rollup — color comes from the data-rollup tone (mirrors §6 phrases). + No inferred "working": status TEXT is one of the five spec phrases only; the tone + adds legibility so a failure does not look identical to a success. */ +.uplink-status { font-size: var(--font-size-sm, 13px); } +.uplink-status__rollup { margin: 0.5rem 0 0; font-family: var(--font-mono); } +.uplink-status__rollup[data-rollup="ok"] { color: var(--success, #10B981); } +.uplink-status__rollup[data-rollup="warn"] { color: var(--warning, #F59E0B); } +.uplink-status__rollup[data-rollup="error"] { color: var(--error, #EF4444); } +.uplink-status__rollup[data-rollup="na"] { color: var(--text-muted, #94A3B8); } +.uplink-status__msg { margin: 0.4rem 0; font-size: var(--font-size-sm, 13px); } +.uplink-status__msg--loading { color: var(--text-muted, #94A3B8); } +.uplink-status__msg--empty { color: var(--text-muted, #94A3B8); } +.uplink-status__msg--error { color: var(--error, #EF4444); } +.uplink-status__stamp { margin: 0.4rem 0 0; font-size: var(--font-size-xs, 12px); color: var(--text-muted, #94A3B8); } +/* Server role label (TIOT / Update / DGPS) shown beside the IP in the Server column. */ +.uplink-target__role { display: inline-block; min-width: 3.2em; margin-right: 0.5rem; font-weight: 600; color: var(--text-secondary, #CBD5E1); } +/* Pre-apply advisories (soft, informational) under the uplink selector. */ +#uplink-advisories { margin: 0.4rem 0 0.2rem; } +.uplink-status__msg--warning { color: var(--warning, #F59E0B); } diff --git a/src/static/index.html b/src/static/index.html index 1cf7866..d22ad32 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -89,6 +89,13 @@ Server Setting + +
  • + + Telemetry Uplink + +
  • diff --git a/src/static/js/api.js b/src/static/js/api.js index 368cc88..85c0a48 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -7,12 +7,26 @@ const API_BASE = 'setting'; +/** + * Fetch with an AbortController timeout. The timer is cleared on completion. + * On timeout the fetch rejects with an AbortError (flows into loadAllData catch). + * @param {string} url + * @param {RequestInit} opts + * @param {number} ms - Timeout in milliseconds (generous default 15 s for slow devices) + * @returns {Promise} + */ +export function fetchWithTimeout(url, opts = {}, ms = 15000) { + const ctrl = new AbortController(); + const id = setTimeout(() => ctrl.abort(), ms); + return fetch(url, { ...opts, signal: ctrl.signal }).finally(() => clearTimeout(id)); +} + /** * GET /setting/get-device * @returns {Promise} Device config or null if not set */ export async function getDevice() { - const res = await fetch(`${API_BASE}/get-device`); + const res = await fetchWithTimeout(`${API_BASE}/get-device`); if (res.status === 204) return null; if (!res.ok) throw new Error(`Failed to load device config: ${res.status}`); return res.json(); @@ -44,7 +58,7 @@ export async function saveDevice(data) { * @returns {Promise} Protocol config or null if not set */ export async function getProtocol() { - const res = await fetch(`${API_BASE}/get-protocol`); + const res = await fetchWithTimeout(`${API_BASE}/get-protocol`); if (res.status === 204) return null; if (!res.ok) throw new Error(`Failed to load protocol config: ${res.status}`); return res.json(); @@ -144,7 +158,7 @@ export async function deleteLogFilesApi(files) { */ export async function getMac() { try { - const res = await fetch('api/mac'); + const res = await fetchWithTimeout('api/mac'); if (!res.ok) return null; const data = await res.json(); return data.mac || null; @@ -256,3 +270,25 @@ export async function restoreCheck() { if (!res.ok) throw new Error(r.error || `restore-check failed: ${res.status}`); return r; } + +// ── Telemetry Uplink API (Task 11) ──────────────────────────────────────────── + +/** GET /api/network/uplink → { uplink_config, options, status } */ +export async function getUplink() { + const res = await fetch('api/network/uplink'); + const r = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(r.error || `uplink load failed: ${res.status}`); + return r; +} + +/** POST /api/network/uplink {telemetry_iface} → { ok, state, apply_id } */ +export async function postUplink(body) { + const res = await fetch('api/network/uplink', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}), + }); + const r = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(r.error || `uplink apply failed: ${res.status}`); + return r; +} diff --git a/src/static/js/app.js b/src/static/js/app.js index bf9d1b0..9c393f4 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -12,14 +12,10 @@ import { state, setDevice, setProtocol, buildDevicePayload, buildProtocolPayload import { clearAllFieldErrors, setPageError, clearAllPageErrors, focusErrorField } from './validator.js'; import { getDirtyPages, clearAllDirty, markDirty } from './page-dirty.js'; import { attachDirtyTracking } from './dirty-tracker.js'; -import { renderSsidPage } from './pages/ssid.js'; -import ssidPage from './pages/ssid.js'; // v1.5.0 P2: wifi.js (ssid.js rename) import wifiPage from './pages/wifi.js'; // v1.5.0 P2 T2: ethernet.js NEW (eth + LTE interface IP) import ethernetPage from './pages/ethernet.js'; -import { renderNetworkPage } from './pages/network.js'; -import networkPage from './pages/network.js'; // v1.5.0 P2 T3: server-setting.js (network.js rename) import serverSettingPage from './pages/server-setting.js'; import { renderIoPage } from './pages/io.js'; @@ -46,6 +42,8 @@ import homePage, { setNavigate as setHomeNavigate } from './pages/home.js'; // v1.6.0: Network Apply & Status page import netApplyPage from './pages/net-apply.js'; import wifiApPage from './pages/wifi-ap.js'; // AP: page import +// Telemetry Uplink: per-target /32 host routes for cloud telemetry/RTCM/update. +import uplinkPage from './pages/uplink.js'; // v1.5.0 P1: Lucide SVG icons + sidebar nested import { icon } from './icons.js'; // v1.5.0 P1: nav-guard — navigation dirty modal @@ -62,8 +60,6 @@ const DEBUG = location.hostname === 'localhost' || location.hostname === '127.0. let _deviceMac = null; const PAGE_RENDERERS = { - ssid: renderSsidPage, - network: renderNetworkPage, io: renderIoPage, log: renderLogPage, register: renderRegisterPage, @@ -92,6 +88,7 @@ const PAGES = { opcua: opcuaPage, firmware: firmwarePage, 'net-apply': netApplyPage, // v1.6.0: Network Apply & Status + uplink: uplinkPage, // Telemetry Uplink — self-contained Save&Apply }; let activePage = null; let detachDirtyTracking = null; @@ -155,6 +152,7 @@ function injectSidebarIcons() { 'icon-nav-firmware': { name: 'package', size: 18 }, 'icon-nav-net-apply': { name: 'plug-zap', size: 16 }, // v1.6.0 'icon-nav-wifi-ap': { name: 'wifi', size: 16 }, // AP: nav icon + 'icon-nav-uplink': { name: 'upload', size: 16 }, // Telemetry Uplink (distinct from can-bus 'route') }; Object.entries(map).forEach(([id, spec]) => { const el = document.getElementById(id); @@ -284,7 +282,8 @@ document.addEventListener('DOMContentLoaded', async () => { }); // ─── Data Loading ─────────────────────────────────────────── -async function loadAllData() { +// Exported for direct test access (avoids needing to fire DOMContentLoaded). +export async function loadAllData() { try { if (DEBUG) console.log('[App] Loading data from API...'); const [device, protocol, mac] = await Promise.all([ @@ -299,11 +298,15 @@ async function loadAllData() { setDevice(device); setProtocol(protocol); state.isDirty = false; + // B6: clear the guard flag — a successful load means Save All is safe. + state.configLoadFailed = false; if (DEBUG) console.log('[App] Data loaded. state.device:', state.device); if (DEBUG) console.log('[App] Data loaded. state.protocol:', state.protocol); } catch (e) { console.error('[App] Failed to load config:', e); showToast('Failed to load configuration. Check server connection.', 'error'); + // B6: set the guard flag — Save All must not run with empty defaults. + state.configLoadFailed = true; // Use defaults setDevice(null); setProtocol(null); @@ -546,6 +549,15 @@ export async function handleSaveAll({ skipConfirm = false } = {}) { // Prevent concurrent save operations if (_saving) return false; + // B6: refuse to save when the initial config load failed — empty defaults would + // overwrite the real DB. Cleared by a successful loadAllData(). + if (state.configLoadFailed) { + showToast('Configuration failed to load — reload the page before saving.', 'error'); + const btn = document.getElementById('btn-save-all'); + if (btn) btn.disabled = true; + return false; + } + // v1.5.0 P4b T3: Show confirm modal listing dirty pages if pageDirty matrix available if (!skipConfirm) { const dirtyPages = getDirtyPages(); diff --git a/src/static/js/components/crud-table.js b/src/static/js/components/crud-table.js index 2964846..3f2d179 100644 --- a/src/static/js/components/crud-table.js +++ b/src/static/js/components/crud-table.js @@ -34,9 +34,16 @@ function addRow(tbody, columns, data) { const cells = columns.map(col => { const fieldKey = escapeHtml(col.key); if (col.type === 'select') { + const stored = data[col.key]; + // If the stored value is non-empty and not in the static option list (e.g. a + // legacy token or casing drift), prepend it as a selected option so it round-trips + // losslessly instead of silently falling to the first option on save. + const unknownOption = (stored != null && stored !== '' && !col.options.includes(stored)) + ? `` + : ''; return ` `; } diff --git a/src/static/js/confirm-modal.js b/src/static/js/confirm-modal.js index 6ad0de2..d56aabc 100644 --- a/src/static/js/confirm-modal.js +++ b/src/static/js/confirm-modal.js @@ -20,11 +20,7 @@ * Focus is trapped inside the dialog and restored to the trigger on close. */ -function escapeHtml(s) { - return String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' - }[ch])); -} +import { escapeHtml } from './utils.js'; /** * @param {Object} opts diff --git a/src/static/js/constants.js b/src/static/js/constants.js index f9149be..041a17a 100644 --- a/src/static/js/constants.js +++ b/src/static/js/constants.js @@ -9,7 +9,7 @@ * Bump APP_VERSION on every release and add a matching CHANGELOG.md entry. */ export const APP_NAME = 'DP World Smart Solutions'; -export const APP_VERSION = 'v1.11.16'; +export const APP_VERSION = 'v1.12.1'; /** * v1.7.0 — pageId → 표시명 (Pending Changes 패널·배지에서 사용). @@ -34,6 +34,7 @@ export const PAGE_DISPLAY_NAMES = { log: 'Log', firmware: 'Firmware', 'net-apply': 'Apply & Status', + uplink: 'Telemetry Uplink', }; /** diff --git a/src/static/js/icons.js b/src/static/js/icons.js index 36edee7..06f8b10 100644 --- a/src/static/js/icons.js +++ b/src/static/js/icons.js @@ -73,6 +73,7 @@ const ICONS = { // Status 'check-circle-2': '', 'x-circle': '', + 'help-circle': '', 'alert-triangle': '', 'activity': '', 'hourglass': '', diff --git a/src/static/js/nav-guard.js b/src/static/js/nav-guard.js index b6f0627..29be27f 100644 --- a/src/static/js/nav-guard.js +++ b/src/static/js/nav-guard.js @@ -18,6 +18,7 @@ import { state, discardPageChanges } from './state.js'; import { clearDirty } from './page-dirty.js'; import { showToast } from './toast.js'; +import { escapeHtml } from './utils.js'; /** * @param {string} currentPageId source page id (이동 전) @@ -58,7 +59,7 @@ function _buildModal(currentPageId, resolve) { - -
    -
    -
    - - ${renderIpOctets('wifi_ip', wifi.ip)} -
    -
    - - ${renderIpOctets('wifi_netmask', wifi.netmask)} -
    -
    -
    -
    - - ${renderIpOctets('wifi_gateway', wifi.gateway)} -
    -
    - - ${renderIpOctets('wifi_dns1', wifi.dns1)} -
    -
    -
    -
    - - ${renderIpOctets('wifi_dns2', wifi.dns2)} -
    -
    -
    -
    - - `; - - // Setup IP octet event listeners - setupIpOctets(container); - - // Populate SSID rows - const tbody = document.getElementById('ssid-tbody'); - ssidList.forEach((s, i) => addSsidRow(tbody, s, i)); - - // Add SSID button (max 10 rows) - const addBtn = document.getElementById('ssid-add-btn'); - addBtn.addEventListener('click', () => { - if (tbody.querySelectorAll('tr').length >= MAX_SSID_COUNT) return; - addSsidRow(tbody, { ssid: '', password: '', security: 'wpa/wpa2' }, ssidList.length); - updateSsidAddButton(); - }); - updateSsidAddButton(); - - // Static/DHCP toggle - document.querySelectorAll('input[name="wifi_type"]').forEach(radio => { - radio.addEventListener('change', () => toggleStaticFields()); - }); - toggleStaticFields(); - - // Register data collector - window.__pageCollectors.ssid = collectSsidData; -} - -function addSsidRow(tbody, data, index) { - const tr = document.createElement('tr'); - tr.innerHTML = ` - - -
    - - -
    - - - - - - - - `; - - // Toggle password visibility - tr.querySelector('.input-password__toggle').addEventListener('click', (e) => { - const input = tr.querySelector('input[data-field="password"]'); - input.type = input.type === 'password' ? 'text' : 'password'; - e.target.textContent = input.type === 'password' ? '👁' : '🔒'; - }); - - // Delete row - tr.querySelector('.ssid-delete').addEventListener('click', () => { - tr.remove(); - updateSsidAddButton(); - }); - - tbody.appendChild(tr); -} - -function updateSsidAddButton() { - const btn = document.getElementById('ssid-add-btn'); - const count = document.querySelectorAll('#ssid-tbody tr').length; - if (btn) btn.disabled = count >= MAX_SSID_COUNT; -} - -function toggleStaticFields() { - const isDhcp = document.querySelector('input[name="wifi_type"]:checked')?.value === 'dhcp'; - const fields = document.getElementById('wifi-static-fields'); - if (!fields) return; - fields.querySelectorAll('.form-input').forEach(input => { - input.disabled = isDhcp; - }); - fields.style.opacity = isDhcp ? '0.5' : '1'; -} - -function collectSsidData() { - if (!state.device) state.device = {}; - - // WiFi settings — collect from octet groups. - // country_code MUST be re-collected here: this object replaces state.device.wifi - // wholesale, so omitting it would silently drop the legacy Java value on save. - const wifiType = document.querySelector('input[name="wifi_type"]:checked')?.value || 'static'; - const ccEl = document.getElementById('wifi_country_code'); - const countryCode = normalizeCountryCode( - ccEl ? ccEl.value : (state.device.wifi && state.device.wifi.country_code) - ) || DEFAULTS.WIFI_COUNTRY_CODE; - state.device.wifi = { - type: wifiType, - ip: collectIpValue('wifi_ip'), - netmask: collectIpValue('wifi_netmask'), - gateway: collectIpValue('wifi_gateway'), - dns1: collectIpValue('wifi_dns1'), - dns2: collectIpValue('wifi_dns2'), - country_code: countryCode, - }; - - // SSID list - const rows = document.querySelectorAll('#ssid-tbody tr'); - state.device.ssid_list = Array.from(rows).map(tr => ({ - ssid: tr.querySelector('[data-field="ssid"]')?.value || '', - password: tr.querySelector('[data-field="password"]')?.value || '', - security: tr.querySelector('[data-field="security"]')?.value || 'wpa/wpa2', - })); -} - -// ─── WiFi IP fields ───────────────────────────────────────── -const WIFI_IP_FIELDS = ['wifi_ip', 'wifi_netmask', 'wifi_gateway', 'wifi_dns1', 'wifi_dns2']; -const WIFI_IP_LABELS = { - wifi_ip: 'WiFi IP', wifi_netmask: 'WiFi Subnet Mask', wifi_gateway: 'WiFi Gateway', - wifi_dns1: 'WiFi DNS 1', wifi_dns2: 'WiFi DNS 2', -}; -const WIFI_STATE_KEYS = { wifi_ip: 'ip', wifi_netmask: 'netmask', wifi_gateway: 'gateway', wifi_dns1: 'dns1', wifi_dns2: 'dns2' }; - -// ─── New Page Interface ───────────────────────────────────── -const ssidPage = { - render(container) { - const device = state.device || {}; - const wifi = device.wifi || {}; - const ssidList = device.ssid_list || []; - - container.innerHTML = ` - - -
    -
    -

    📡 SSID List

    - -
    -
    - - - - - - - - - - -
    SSID NamePasswordSecurity
    -
    -

    Up to 10 SSIDs can be registered.

    -
    - -
    -
    -

    🔧 WiFi Connection Settings

    -
    -
    - -
    - - -
    -
    -
    -
    -
    - - ${renderIpOctets('wifi_ip', wifi.ip)} -
    -
    - - ${renderIpOctets('wifi_netmask', wifi.netmask)} -
    -
    -
    -
    - - ${renderIpOctets('wifi_gateway', wifi.gateway)} -
    -
    - - ${renderIpOctets('wifi_dns1', wifi.dns1)} -
    -
    -
    -
    - - ${renderIpOctets('wifi_dns2', wifi.dns2)} -
    -
    -
    -
    -
    - -
    -
    -

    🌍 Region

    -
    -
    - - -

    WiFi regulatory region. Defaults to AE (United Arab Emirates); dpworldapp applies it to the WiFi module.

    -
    -
    - `; - }, - - mount(container) { - setupIpOctets(container); - - // Reactive bindings: WiFi type radio - bindRadio(container, 'wifi_type', - () => (state.device?.wifi?.type || 'static'), - (val) => { - if (!state.device) state.device = {}; - if (!state.device.wifi) state.device.wifi = {}; - state.device.wifi.type = val; - state.isDirty = true; - } - ); - - // Reactive bindings: WiFi IP groups - WIFI_IP_FIELDS.forEach(ipKey => { - const stateKey = WIFI_STATE_KEYS[ipKey]; - bindIpGroup(container, ipKey, - () => state.device?.wifi?.[stateKey] || '', - (val) => { - if (!state.device) state.device = {}; - if (!state.device.wifi) state.device.wifi = {}; - state.device.wifi[stateKey] = val; - state.isDirty = true; - } - ); - }); - - // Reactive binding: WiFi country code ( + ${escapeHtml(label)} + ${hint} + ${reasonNote} + `; + }).join(''); +} + +function _advisoriesHtml() { + const sel = _selected || _currentIface(); + const opt = ((_data && _data.options) || []).find(o => o.iface === sel); + const adv = (opt && opt.advisories) || []; + if (!adv.length) return ''; + return adv.map(a => + `` + ).join(''); +} + +function _renderAdvisories() { + const el = document.getElementById('uplink-advisories'); + if (el) el.innerHTML = _advisoriesHtml(); +} + +function _rollupCode() { + return (_data && _data.status && _data.status.rollup) || null; +} +function _rollupText(code) { return _ROLLUP_TEXT[code] || code || ''; } +function _rollupTone(code) { return _ROLLUP_TONE[code] || 'na'; } + +function _fmtTime(d) { + try { return d.toLocaleTimeString(); } catch (_) { return ''; } +} + +// The dynamic status body: the per-target table (or a distinct loading/empty/error +// message). NOT a live region — the rollup line below carries the announcement. +function _statusBodyHtml() { + if (!_data || !_data.status) { + return _staleError + ? `` + : ``; + } + const s = _data.status; + const rows = (s.per_target || []).map(p => { + // Honesty: "Route ready" describes the host-route being in place — NOT that traffic flows. + // Confirmation of actual traffic is the "Traffic" column (ss-observed egress interface). + // In default-route mode (wlan0) NO per-server /32 is expected, so "No route yet" would + // mis-read as a fault — show "System default" instead. + // §5.1 fate — first match wins; on-link rows take precedence over mode/route rows because + // an on-link target's egress is mode-independent (it always uses its own LAN). + let routeText; + if (p.on_link_iface && p.on_link_iface === s.iface) { + routeText = `On ${p.on_link_iface} — local (direct)`; + } else if (p.on_link_iface) { + routeText = `On ${p.on_link_iface} — local LAN (not steered)`; + } else if (s.iface === _DEFAULT_IFACE) { + routeText = 'System default'; + } else if (p.route_installed) { + routeText = 'Route ready'; // route PRESENT, not a claim traffic flows (see Traffic column) + } else { + routeText = 'No route yet'; + } + const traffic = p.egress_iface || 'Not yet observed'; + // Server cell: role label (TIOT / Update / DGPS) alongside the IP, when known. + const server = p.label + ? `${escapeHtml(p.label)} ${escapeHtml(p.ip)}` + : escapeHtml(p.ip); + return ` + + ${server} + ${escapeHtml(routeText)} + ${escapeHtml(traffic)} + `; + }).join(''); + const empty = (s.per_target || []).length === 0 + ? `No telemetry targets configured (set protocol/update/RTCM server IPs first).` + : ''; + const errBanner = _staleError + ? `` + : ''; + const stamp = _lastUpdated + ? `` + : ''; + return ` + ${errBanner} +
    + + + ${rows}${empty} + +
    + ${stamp}`; +} + +function _render(container) { + const code = _rollupCode(); + _lastRollup = code; + container.innerHTML = ` + + + `; +} + +function _bindRadios(scope) { + scope.querySelectorAll('input[name="telemetry_iface"]').forEach(r => { + r.addEventListener('change', () => { + if (r.checked && !r.disabled) { _selected = r.value; _renderAdvisories(); } + }); + }); +} + +function _bind(container) { + _bindRadios(container); + container.querySelector('#uplink-save-apply')?.addEventListener('click', () => _saveAndApply()); +} + +// Update ONLY the dynamic subtrees, preserving radio keyboard focus and not +// spamming screen readers (rollup re-announced only when its code changes). +function _applyRefreshDom() { + const optEl = document.getElementById('uplink-options'); + // Don't rebuild the radio group while the operator is focused inside it (would steal focus mid-selection). + if (optEl && !optEl.contains(document.activeElement)) { + optEl.innerHTML = _optionsHtml(); + _bindRadios(optEl); + } + const bodyEl = document.getElementById('uplink-status-body'); + if (bodyEl) bodyEl.innerHTML = _statusBodyHtml(); + const code = _rollupCode(); + if (code !== _lastRollup) { + const rEl = document.getElementById('uplink-rollup'); + if (rEl) { + rEl.setAttribute('data-rollup', _rollupTone(code)); + rEl.textContent = _rollupText(code); + } + _lastRollup = code; + } + _renderAdvisories(); +} + +async function _refresh() { + const myEpoch = _epoch; + let data; + try { + data = await getUplink(); + } catch (e) { + if (myEpoch !== _epoch) return; // torn down during await — don't touch the DOM + // Fail-soft: keep last-good _data and the last-known table visible; surface a + // distinct error notice over it instead of blanking the panel (matches peer poll convention). + _staleError = e.message || 'load failed'; + const bodyEl = document.getElementById('uplink-status-body'); + if (bodyEl) bodyEl.innerHTML = _statusBodyHtml(); + return; + } + if (myEpoch !== _epoch) return; // page torn down / re-mounted while the GET was in flight + _data = data; + _staleError = null; + _lastUpdated = new Date(); + // Reconcile UI selection with backend: if the user hasn't picked anything yet, + // mirror the backend's current iface; if they had, keep their choice when valid. + const valid = new Set((_data.options || []).filter(o => o.gateway_present).map(o => o.iface)); + if (!_selected || !valid.has(_selected)) _selected = _currentIface(); + if (document.getElementById('uplink-status-body')) { + _applyRefreshDom(); // subtree update — preserves focus + avoids SR churn + } else { + const c = document.getElementById('page-container'); // first-mount race fallback + if (c) { _render(c); _bind(c); } + } +} + +async function _saveAndApply() { + if (_saving) return; + const iface = _selected || _currentIface(); + if (!_data) { + showToast('Uplink data not loaded yet.', 'error'); + return; + } + const opt = (_data.options || []).find(o => o.iface === iface); + if (!opt || !opt.gateway_present) { + showToast(`Cannot select ${iface}: ${opt?.disabled_reason || 'no gateway'}.`, 'error'); + return; + } + if (iface === _currentIface()) { + showToast(`Telemetry is already routed over ${iface}.`, 'info'); + return; + } + const adv = (opt && opt.advisories) || []; + const advText = adv.length ? `Effects of this selection: ${adv.join(' ')} ` : ''; + const ok = await confirmModal({ + title: 'Switch telemetry uplink?', + message: `${advText}Telemetry will switch to ${iface}. To make the switch take effect now, ${_RESTART_ACTION} — until then dpworldapp keeps its current connections on the old path. If the servers are not reachable via ${iface}, transmission stops until you switch back. Management access (this configurator, SSH) is not affected.`, + confirmLabel: 'Switch & Apply', + cancelLabel: 'Cancel', + danger: false, // route-only, non-disruptive to management access; the caveat lives in the message body + }); + if (!ok) return; + _saving = true; + const saveApplyBtn = document.getElementById('uplink-save-apply'); + const origBtnHtml = saveApplyBtn ? saveApplyBtn.innerHTML : ''; + if (saveApplyBtn) { saveApplyBtn.disabled = true; saveApplyBtn.textContent = 'Saving…'; } + try { + await postUplink({ telemetry_iface: iface }); + clearDirty('uplink'); + // Reinforce the action: routes are applied, but dpworldapp must reconnect to switch. + showToast(`Uplink switch applied — ${_RESTART_ACTION} to take effect now.`, 'success'); + } catch (e) { + showToast('Failed — ' + (e.message || 'apply error'), 'error'); + } finally { + _saving = false; + if (saveApplyBtn) { saveApplyBtn.disabled = false; saveApplyBtn.innerHTML = origBtnHtml; } + } + await _refresh(); // re-render AFTER _saving cleared so the button re-enables immediately +} + +const uplinkPage = { + render(container) { _render(container); _bind(container); }, + mount(container) { + _refresh(); + if (!_statusTimer) _statusTimer = setInterval(_refresh, 15000); + }, + destroy() { + _epoch++; // orphan any in-flight _refresh so it won't write to a torn-down/other page + if (_statusTimer) { clearInterval(_statusTimer); _statusTimer = null; } + _data = null; + _selected = null; + _saving = false; + _lastRollup = null; + _lastUpdated = null; + _staleError = null; + }, + validate() { return []; }, // route-only; selection is constrained by radios + async saveSelfContained() { + // Global Save All entry point: route-only apply, no confirm modal (Save All + // already gathered confirmation upstream for the whole batch). + const iface = _selected || _currentIface(); + if (iface === _currentIface()) return; + await postUplink({ telemetry_iface: iface }); + clearDirty('uplink'); + // Refresh only when the uplink page is actually mounted (no-op during a batch + // Save All triggered from another page — avoids rendering into a foreign container). + if (document.getElementById('uplink-status')) await _refresh(); + }, +}; +export default uplinkPage; diff --git a/src/static/js/pages/wifi-ap.js b/src/static/js/pages/wifi-ap.js index c047f7e..294546f 100644 --- a/src/static/js/pages/wifi-ap.js +++ b/src/static/js/pages/wifi-ap.js @@ -9,15 +9,40 @@ import { clearDirty } from '../page-dirty.js'; const _DEFAULTS = { ap_enabled: false, ap_ssid: '', ap_passphrase: '', ap_band: 'auto', ap_channel: 0, ap_hidden: false }; let _cfg = { ..._DEFAULTS }; // last loaded/saved config -let _status = {}; // live status (ap_enabled, ap0_up, hostapd_running, clients, country_pending) +let _status = {}; // live status (ap_enabled, ap0_up, hostapd_running, clients, client_details) let _countryPending = false; let _statusTimer = null; +// Fix #3b: track whether the backend has a stored PSK (from status.has_passphrase). +// null = not yet loaded (status not fetched); true/false = known. +// Used in _validate() to reject blank PSK when AP is being enabled on a fresh device. +let _hasPassphrase = null; // v1.11.9 (review LOW concurrency): _save is async and bound to three buttons // (ap-save / ap-apply / ap-save-apply) — guard against a double-apply re-entry. let _saving = false; function _byteLen(s) { return new TextEncoder().encode(s || '').length; } function _badge(cls, text) { return `${escapeHtml(text)}`; } +function _fmtDbm(v) { return Number.isFinite(v) ? `${v} dBm` : 'Unknown'; } +function _fmtIdle(v) { return Number.isFinite(v) ? `${v} ms` : 'Unknown'; } + +function _clientDetailsHtml() { + const clients = Array.isArray(_status.client_details) ? _status.client_details : []; + if (!clients.length) return ''; + const rows = clients.map(c => ` + + ${escapeHtml(c.mac || 'unknown')} + ${escapeHtml(c.ip || 'IP pending')} + ${escapeHtml(_fmtDbm(c.signal_dbm))} + ${escapeHtml(_fmtIdle(c.inactive_ms))} + `).join(''); + return ` +
    + + + ${rows} +
    MACIPSignalIdle
    +
    `; +} async function _api(path, opts) { const res = await fetch(path, opts); @@ -55,11 +80,17 @@ function _validate() { // v1.11.9 (review HIGH security UX): a BLANK PSK field means "keep the existing // stored password" (_collect omits ap_passphrase entirely). Only validate the // 8-63 byte bound when the user actually typed a NEW passphrase. + // Fix #3b: on a fresh device (_hasPassphrase=false) a blank PSK has nothing to + // "keep" — the server-side RMW would store an empty PSK and fail apply-time + // validation. Reject proactively so the error is shown client-side immediately. if ('ap_passphrase' in d) { const p = _byteLen(d.ap_passphrase); if (p < 8 || p > 63) { showFieldError(document.getElementById('ap_passphrase'), 'Password must be 8-63 bytes.'); errs.push('psk'); } + } else if (_hasPassphrase === false) { + // Blank PSK + no stored PSK: require the user to set one now. + showFieldError(document.getElementById('ap_passphrase'), 'Wi-Fi AP requires a passphrase (8–63 characters).'); errs.push('psk'); } } setPageError('wifi-ap', errs.length > 0); @@ -77,7 +108,7 @@ function _statusHtml() { const apBadge = running ? _badge('ok', 'Running') : _badge('na', 'Off'); const rows = [ `
    Access point${apBadge}
    `, - `
    ${icon('smartphone', { size: 14 })} Connected clients + `
    ${icon('smartphone', { size: 14 })} Associated clients ${escapeHtml(String(_status.clients ?? 0))}
    `, ]; if (adv) { @@ -90,7 +121,7 @@ function _statusHtml() { } const refresh = adv ? `` : ''; - return `

    ${icon('activity', { size: 16 })} Status ${refresh}

    ${rows.join('')}
    `; + return `

    ${icon('activity', { size: 16 })} Status ${refresh}

    ${rows.join('')}${_clientDetailsHtml()}
    `; } function _render(container) { @@ -176,6 +207,8 @@ async function _refreshStatus() { const s = await _api('api/network/ap/status'); _status = s || {}; _countryPending = !!s.country_pending; + // Fix #3b: capture has_passphrase (boolean from backend, null = not yet known). + if (typeof s.has_passphrase === 'boolean') _hasPassphrase = s.has_passphrase; if (s.config) _cfg = { ..._DEFAULTS, ...s.config }; const el = document.getElementById('ap-status'); if (el) { @@ -202,6 +235,7 @@ const wifiApPage = { _status = {}; _countryPending = false; _saving = false; + _hasPassphrase = null; // Fix #3b: reset so next mount re-fetches from backend }, validate() { if (!document.getElementById('ap_enabled')) return []; diff --git a/src/static/js/state.js b/src/static/js/state.js index eb5665f..fef9f84 100644 --- a/src/static/js/state.js +++ b/src/static/js/state.js @@ -16,6 +16,9 @@ export const state = { protocol: null, currentPage: 'ssid', isDirty: false, + /** B6: set true when loadAllData catches an error; cleared on successful load. + * handleSaveAll refuses to run when true to prevent empty-defaults from wiping real DB. */ + configLoadFailed: false, /** * v1.5.0 P1: per-page dirty matrix. * Phase 1: 9 기존 page id + firmware placeholder. @@ -40,6 +43,7 @@ export const state = { log: false, firmware: false, 'net-apply': false, // v1.6.0 + uplink: false, // Telemetry Uplink — self-contained Save&Apply }, }; @@ -453,7 +457,7 @@ function _arraySummary(oldArr, newArr) { if (o.length !== n.length) { return { old: `${o.length} entries`, new: `${n.length} entries` }; } - return { old: '(내용 변경)', new: '(내용 변경)' }; + return { old: '(modified)', new: '(modified)' }; } /** @@ -503,7 +507,7 @@ function _diffTree(baseline, current, domain, attributeTop, attributeNested) { // Deeper than one level — compare by JSON equality, summarize. if (JSON.stringify(sbv) !== JSON.stringify(scv)) { const page = attributeNested(key, sk); - out.push({ page, domain, field: `${key}.${sk}`, old: '(내용 변경)', new: '(내용 변경)' }); + out.push({ page, domain, field: `${key}.${sk}`, old: '(modified)', new: '(modified)' }); } continue; } diff --git a/src/support_bundle.py b/src/support_bundle.py index f2d37bb..e4e9d0c 100644 --- a/src/support_bundle.py +++ b/src/support_bundle.py @@ -30,23 +30,6 @@ def mask_passwords(device_config): return cfg -def _newest_log_path(log_dir): - if not os.path.isdir(log_dir): - return None - newest, newest_mtime = None, 0 - try: - for name in os.listdir(log_dir): - if not name.endswith(".log"): - continue - p = os.path.join(log_dir, name) - if os.path.isfile(p): - m = os.stat(p).st_mtime - if m > newest_mtime: - newest, newest_mtime = p, m - except OSError: - return None - return newest - def build_support_bundle(db): """Build the diagnostics zip. Returns the zip file content as bytes.""" @@ -68,7 +51,7 @@ def build_support_bundle(db): json.dumps(protocol, indent=2, ensure_ascii=False)) # newest dpworldapp log (capped) - log_path = _newest_log_path(system_status.LOG_DIR) + log_path = system_status._find_newest_dpworld_log(system_status.LOG_DIR) if log_path: try: with open(log_path, "rb") as f: diff --git a/src/system_status.py b/src/system_status.py index 8ba74f5..0d48de0 100644 --- a/src/system_status.py +++ b/src/system_status.py @@ -35,6 +35,7 @@ LOG_IDLE_SECONDS = 300 # log mtime younger than this → idle, else stale # scanners use a line iterator (Python buffered I/O) and need no # chunk size or cap — memory is bounded by line length. DPWORLD_LOG_TAIL_BYTES = 4096 +_FUTURE_LOG_SKEW_S = 60 # files dated more than this far in the future are skipped # ─── v1.2.0: compiled regex patterns for log scanning ────────── _FWVE_RE = re.compile(r'"FWVE"\s*:\s*"([^"]+)"') @@ -235,6 +236,8 @@ def _find_newest_dpworld_log(log_dir): if not os.path.isfile(p): continue m = os.stat(p).st_mtime + if m > time.time() + _FUTURE_LOG_SKEW_S: + continue if m > newest_mtime: newest_mtime, newest_path = m, p except OSError: @@ -660,8 +663,6 @@ def get_dpworldapp_status(db, log_dir=LOG_DIR): - phase markers scanned from the relevant log file (cached + frozen once steady_state is reached) - runtime config from board_config (protocol_config + device_config) - - Shape follows the dpworldapp runtime status contract. """ global _dpworld_status_cache @@ -776,8 +777,10 @@ def get_log_freshness(log_dir): last_line = stripped except OSError: pass + now = time.time() + age_seconds = None if mtime > now + _FUTURE_LOG_SKEW_S else max(0.0, now - mtime) return { - "age_seconds": max(0.0, time.time() - mtime), + "age_seconds": age_seconds, "last_line": last_line, "dpworld_version": _get_dpworld_version_cached(log_dir), } @@ -864,7 +867,7 @@ def get_process_status(unit=DPWORLDAPP_UNIT): proc_pid = _dpworldapp_pid_from_proc() if proc_pid is not None: return {"running": True, "unit_managed": False, - "pid": proc_pid, "uptime_seconds": uptime_seconds, "unit": unit, + "pid": proc_pid, "uptime_seconds": None, "unit": unit, "detail": "running, not under systemd supervision"} return {"running": False, "unit_managed": False,