diff --git a/.gitea/workflows/build_android_agent.yaml b/.gitea/workflows/build_android_agent.yaml new file mode 100644 index 0000000..b006beb --- /dev/null +++ b/.gitea/workflows/build_android_agent.yaml @@ -0,0 +1,130 @@ +name: Build Android Agent + +on: + workflow_call: + inputs: + build_type: + description: 'Build type (debug or release)' + required: false + type: string + default: 'release' + +jobs: + build: + runs-on: ubuntu-latest + env: + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Configure git auth for submodules + run: git config --global url."https://x-access-token:${{ secrets.BUILD_TOKEN }}@git.datadunia.com/".insteadOf "https://git.datadunia.com/" + + - uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 + token: ${{ secrets.BUILD_TOKEN }} + persist-credentials: true + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Check for signing key + id: check-signing + run: | + if [ -n "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" ]; then + echo "has_key=true" >> "$GITHUB_OUTPUT" + echo "Signing: Using production keystore from secrets" + else + echo "has_key=false" >> "$GITHUB_OUTPUT" + echo "Signing: No keystore found, using debug/self-signed" + fi + + - name: Decode keystore + if: steps.check-signing.outputs.has_key == 'true' + run: | + echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > /tmp/release.keystore + + - name: Create keystore properties + if: steps.check-signing.outputs.has_key == 'true' + run: | + cat > /tmp/keystore.properties << EOF + storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }} + keyAlias=${{ secrets.ANDROID_KEY_ALIAS }} + storeFile=/tmp/release.keystore + EOF + + - name: Configure signing (production) + if: steps.check-signing.outputs.has_key == 'true' + working-directory: apps/android-agent + run: | + # Inject signing config into build.gradle.kts + cat >> app/build.gradle.kts << 'GRADLE_EOF' + + android { + signingConfigs { + create("release") { + storeFile = file("/tmp/release.keystore") + storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "" + keyAlias = System.getenv("KEY_ALIAS") ?: "" + keyPassword = System.getenv("KEY_PASSWORD") ?: "" + } + } + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } + } + GRADLE_EOF + env: + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + + - name: Grant execute permission for gradlew + working-directory: apps/android-agent + run: chmod +x gradlew + + - name: Build debug APK (no signing key) + if: steps.check-signing.outputs.has_key == 'false' + working-directory: apps/android-agent + run: ./gradlew assembleDebug + + - name: Build release APK (with signing key) + if: steps.check-signing.outputs.has_key == 'true' + working-directory: apps/android-agent + run: ./gradlew assembleRelease + + - name: Upload debug APK + if: steps.check-signing.outputs.has_key == 'false' + uses: actions/upload-artifact@v3 + with: + name: nexusguard-android-debug + path: apps/android-agent/app/build/outputs/apk/debug/app-debug.apk + + - name: Upload release APK + if: steps.check-signing.outputs.has_key == 'true' + uses: actions/upload-artifact@v3 + with: + name: nexusguard-android-release + path: apps/android-agent/app/build/outputs/apk/release/app-release.apk + + - name: Upload mapping file (release only) + if: steps.check-signing.outputs.has_key == 'true' + uses: actions/upload-artifact@v3 + with: + name: nexusguard-android-mapping + path: apps/android-agent/app/build/outputs/mapping/release/mapping.txt + if-no-files-found: warn + + - name: Cleanup keystore + if: always() + run: rm -f /tmp/release.keystore /tmp/keystore.properties diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e5d4330..fadb0ec 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -8,13 +8,17 @@ on: - 'v*-beta*' - 'v*-test*' - 'v[0-9]*.[0-9]*.[0-9]' + pull_request: + branches: + - main + - develop jobs: # ==================================================================== # TESTS — test tags only # ==================================================================== server-core-test: - if: contains(gitea.ref_name, 'test') + if: contains(gitea.ref_name, 'test') || github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -35,7 +39,7 @@ jobs: run: go test $(go list ./... | grep -v internal/firewall) -tags dev -cover -count=1 device-agent-test: - if: contains(gitea.ref_name, 'test') + if: contains(gitea.ref_name, 'test') || github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -56,7 +60,7 @@ jobs: run: go test ./... -cover dashboard-test: - if: contains(gitea.ref_name, 'test') + if: contains(gitea.ref_name, 'test') || github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -77,36 +81,70 @@ jobs: working-directory: apps/dashboard-ui run: npm run build + android-agent-test: + if: contains(gitea.ref_name, 'test') || github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 + token: ${{ secrets.BUILD_TOKEN }} + persist-credentials: true + - name: Configure git auth for submodules + run: git config --global url."https://x-access-token:${{ secrets.BUILD_TOKEN }}@git.datadunia.com/".insteadOf "https://git.datadunia.com/" + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + - name: Grant execute permission for gradlew + working-directory: apps/android-agent + run: chmod +x gradlew + - name: Build debug APK + working-directory: apps/android-agent + run: ./gradlew assembleDebug + # ==================================================================== # BUILD — after tests pass (release) or directly (dev/beta) # ==================================================================== build-server-core: if: always() && !contains(gitea.ref_name, 'test') && !failure() && !cancelled() - needs: [server-core-test, device-agent-test, dashboard-test] + needs: [server-core-test, device-agent-test, dashboard-test, android-agent-test] runs-on: ubuntu-latest uses: ./.gitea/workflows/build_server_core.yaml secrets: inherit build-device-agent: if: always() && !contains(gitea.ref_name, 'test') && !failure() && !cancelled() - needs: [server-core-test, device-agent-test, dashboard-test] + needs: [server-core-test, device-agent-test, dashboard-test, android-agent-test] runs-on: ubuntu-latest uses: ./.gitea/workflows/build_device_agent.yaml secrets: inherit build-dashboard: if: always() && !contains(gitea.ref_name, 'test') && !failure() && !cancelled() - needs: [server-core-test, device-agent-test, dashboard-test] + needs: [server-core-test, device-agent-test, dashboard-test, android-agent-test] runs-on: ubuntu-latest uses: ./.gitea/workflows/build_dashboard.yaml secrets: inherit + build-android-agent: + if: always() && !contains(gitea.ref_name, 'test') && !failure() && !cancelled() + needs: [server-core-test, device-agent-test, dashboard-test, android-agent-test] + runs-on: ubuntu-latest + uses: ./.gitea/workflows/build_android_agent.yaml + secrets: inherit + # ==================================================================== # DOCS — after builds pass, release tags only # ==================================================================== build-docs: if: always() && !contains(gitea.ref_name, 'dev') && !contains(gitea.ref_name, 'beta') && !contains(gitea.ref_name, 'test') && !failure() && !cancelled() - needs: [build-server-core, build-device-agent, build-dashboard] + needs: [build-server-core, build-device-agent, build-dashboard, build-android-agent] runs-on: ubuntu-latest uses: ./.gitea/workflows/docs_call.yaml secrets: inherit @@ -116,9 +154,31 @@ jobs: # ==================================================================== release: if: always() && !contains(gitea.ref_name, 'test') && !failure() && !cancelled() - needs: [build-server-core, build-device-agent, build-dashboard, build-docs] + needs: [build-server-core, build-device-agent, build-dashboard, build-android-agent, build-docs] uses: ./.gitea/workflows/release_call.yaml with: prerelease: ${{ contains(gitea.ref_name, 'dev') }} draft: ${{ contains(gitea.ref_name, 'beta') }} secrets: inherit + + # ==================================================================== + # DEPLOY DEV — auto-deploy on dev tags + # ==================================================================== + deploy-dev: + if: contains(gitea.ref_name, 'dev') && !failure() && !cancelled() + needs: [build-server-core, build-device-agent, build-dashboard, build-android-agent] + uses: ./.gitea/workflows/deploy_call.yaml + with: + environment: dev + secrets: inherit + + # ==================================================================== + # DEPLOY PROD — manual trigger after release + # ==================================================================== + deploy-prod: + if: startsWith(gitea.ref_name, 'v') && !contains(gitea.ref_name, 'dev') && !contains(gitea.ref_name, 'beta') && !contains(gitea.ref_name, 'test') && !failure() && !cancelled() + needs: [build-server-core, build-device-agent, build-dashboard, build-android-agent, build-docs] + uses: ./.gitea/workflows/deploy_call.yaml + with: + environment: production + secrets: inherit diff --git a/.gitea/workflows/deploy_call.yaml b/.gitea/workflows/deploy_call.yaml new file mode 100644 index 0000000..af64e49 --- /dev/null +++ b/.gitea/workflows/deploy_call.yaml @@ -0,0 +1,89 @@ +name: Deploy + +on: + workflow_call: + inputs: + environment: + description: 'Target environment (dev or production)' + required: true + type: string + +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Configure git auth for submodules + run: git config --global url."https://x-access-token:${{ secrets.BUILD_TOKEN }}@git.datadunia.com/".insteadOf "https://git.datadunia.com/" + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.BUILD_TOKEN }} + persist-credentials: true + + - name: Download all artifacts + uses: actions/download-artifact@v3 + with: + path: ./artifacts + + - name: Deploy to ${{ inputs.environment }} + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + run: | + echo "Deploying to ${{ inputs.environment }}..." + echo "Host: $DEPLOY_HOST" + echo "Path: $DEPLOY_PATH" + + # Setup SSH + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + # Upload artifacts + echo "Uploading server-core..." + scp -i ~/.ssh/deploy_key artifacts/server-core-linux-amd64/server-core \ + "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/bin/" + + echo "Uploading device-agent..." + for platform in linux-amd64 linux-arm64; do + scp -i ~/.ssh/deploy_key "artifacts/nexus-device-agent-${platform}/nexus-device-agent-${platform}" \ + "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/bin/" + done + + echo "Uploading dashboard..." + if [ -d "artifacts/dashboard-ui-dist/dist" ]; then + tar -czf /tmp/dashboard-ui.tar.gz -C artifacts/dashboard-ui-dist dist/ + scp -i ~/.ssh/deploy_key /tmp/dashboard-ui.tar.gz \ + "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/" + fi + + # Deploy on server + echo "Running deployment..." + ssh -i ~/.ssh/deploy_key "${DEPLOY_USER}@${DEPLOY_HOST}" << 'DEPLOY_SCRIPT' + cd "${DEPLOY_PATH}" || exit 1 + + # Stop services + echo "Stopping services..." + docker compose down || true + + # Run update script + echo "Running update..." + bash update.sh --force + + # Verify services + echo "Verifying services..." + docker compose ps + + echo "Deployment complete!" + DEPLOY_SCRIPT + + echo "Deployed to ${{ inputs.environment }} successfully!" + + - name: Cleanup + if: always() + run: rm -rf ~/.ssh/deploy_key /tmp/dashboard-ui.tar.gz diff --git a/.gitea/workflows/release_call.yaml b/.gitea/workflows/release_call.yaml index a920cc5..68cb3f8 100644 --- a/.gitea/workflows/release_call.yaml +++ b/.gitea/workflows/release_call.yaml @@ -49,7 +49,8 @@ jobs: "device-agent-linux-amd64": "${SERVER}/${REPO}/releases/download/${VERSION}/nexus-device-agent-linux-amd64", "device-agent-linux-arm64": "${SERVER}/${REPO}/releases/download/${VERSION}/nexus-device-agent-linux-arm64", "device-agent-windows-amd64": "${SERVER}/${REPO}/releases/download/${VERSION}/nexus-device-agent-windows-amd64.exe", - "dashboard-ui": "${SERVER}/${REPO}/releases/download/${VERSION}/dashboard-ui-dist.tar.gz" + "dashboard-ui": "${SERVER}/${REPO}/releases/download/${VERSION}/dashboard-ui-dist.tar.gz", + "android-agent": "${SERVER}/${REPO}/releases/download/${VERSION}/nexusguard-android.apk" } } ENDJSON @@ -115,6 +116,13 @@ jobs: upload_asset "dashboard-ui-dist.tar.gz" "dashboard-ui-dist.tar.gz" fi + # Android Agent APK + if [ -f "artifacts/nexusguard-android-debug/nexusguard-android-debug.apk" ]; then + upload_asset "artifacts/nexusguard-android-debug/nexusguard-android-debug.apk" "nexusguard-android.apk" + elif [ -f "artifacts/nexusguard-android-release/nexusguard-android-release.apk" ]; then + upload_asset "artifacts/nexusguard-android-release/nexusguard-android-release.apk" "nexusguard-android.apk" + fi + # Docs tar.gz if [ -d "artifacts/docs-dist" ]; then cd artifacts/docs-dist diff --git a/AGENTS.md b/AGENTS.md index 1beb0e5..707ab2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,8 +118,8 @@ HTTP + gRPC share port 8080 via `cmux`: 6. On heartbeat config change → rebuild tunnel (30s delay, HTTP fallback) 7. On heartbeat stale handshake → rebuild tunnel 8. On heartbeat failure+recovery → rebuild tunnel -9. On gRPC suspend → stop tunnel, heartbeat continues -10. On gRPC resume → rebuild tunnel from server config +9. On gRPC suspend → send heartbeat(tunnel_up=true) → stop tunnel (DisconnectReason.SUSPENDED) → heartbeat continues +10. On gRPC resume → rebuild tunnel from server config → send heartbeat(tunnel_up=true) ### Protobuf Messages - **Agent → Server**: HelloMessage, HeartbeatAck, StatusReport, PingMessage diff --git a/apps/android-agent b/apps/android-agent index 3c50588..b70fb93 160000 --- a/apps/android-agent +++ b/apps/android-agent @@ -1 +1 @@ -Subproject commit 3c50588f7d8d16e520381d29bc8bc60fa2571b0d +Subproject commit b70fb93e66fbd1e1a413de240152b5202777cf82 diff --git a/apps/device-agent b/apps/device-agent index c8d1568..481c232 160000 --- a/apps/device-agent +++ b/apps/device-agent @@ -1 +1 @@ -Subproject commit c8d15687cf778cfd89bbc692a14fcbe0832db183 +Subproject commit 481c23260194daf1919ce7739d38f36ae2c6166b diff --git a/docs/UI_CHANGES.md b/docs/UI_CHANGES.md new file mode 100644 index 0000000..e7f9209 --- /dev/null +++ b/docs/UI_CHANGES.md @@ -0,0 +1,85 @@ +# Android Agent UI Redesign + +**Date:** 2026-07-04 +**Scope:** NexusGuard Android Agent main activity UI + +## Summary + +Complete redesign of the Android agent main activity UI with a dark theme, green accent color scheme, copy-tap support, and new port forwarding display. + +## Files Changed + +| File | Change | +|------|--------| +| `app/src/main/res/values/strings.xml` | Added 13 new string resources for UI labels | +| `app/src/main/res/values/colors.xml` | Added 8 new color definitions (dark/green theme) | +| `app/src/main/res/values/themes.xml` | Applied custom Material3 theme attributes | +| `app/src/main/res/layout/activity_main.xml` | Complete layout redesign | +| `app/src/main/java/com/datadunia/nexusguard/MainActivity.kt` | Updated Kotlin bindings and logic | + +## Design Changes + +### Theme +- Dark background (`#0D1117`) with green accent (`#00C853`) +- Material3 `DayNight.NoActionBar` parent theme with custom color overrides +- Card borders use `#21262D` stroke color + +### Header +- App name "NexusGuard" displayed in green accent color +- MaterialSwitch for VPN toggle with ON/OFF labels +- Status text below switch (colored by state) + +### Status Device Agent Card +- Renamed from "Informasi Tunnel" to "Status Device Agent" +- Six data rows: IP Address, Admin Web, Device ID, Handshake Terakhir, Allowed IPs, Transport +- All value TextViews are selectable (`textIsSelectable="true"`) and copy-tappable +- Device ID displays full text without truncation (no `ellipsize` or `singleLine`) +- Admin Web shows fixed domain `https://api-nexus.datadunia.com` + +### Port Forwarding Card (New) +- Displays active port forwards from `PortForwarder.getActiveForwards()` +- Shows protocol badge (TCP/UDP) and port mapping info +- Empty state message when no forwards are active + +### Log Card +- Height reduced from 300dp to 250dp +- Monospace font at 10sp +- Log level filtering: WARN+ in release, all levels in debug + +## Behavior Changes + +### Copy-Tap +All value TextViews in the status card respond to tap with clipboard copy and "Disalin" Toast feedback. + +### Log Level Filtering +`LogBuffer.initLevel(BuildConfig.DEBUG)` called in `onCreate()` — release builds only store WARN+ entries (50 max), debug builds store all. + +### Port Forward Updates +`updatePortForwardCard()` called after every `updateCardFields()` in all tunnel states (CONNECTING, CONNECTED, FAILED). + +## Layout IDs + +| ID | Type | Purpose | +|----|------|---------| +| `switchVpn` | MaterialSwitch | VPN toggle | +| `tvStatus` | TextView | Connection status text | +| `cardInfo` | MaterialCardView | Status Device Agent card | +| `tvIpAddress` | TextView | Internal IP value | +| `tvAdminWeb` | TextView | Admin web domain (was `tvServerEndpoint`) | +| `tvDeviceId` | TextView | Device ID value | +| `tvHandshake` | TextView | Last handshake time | +| `tvAllowedIps` | TextView | Allowed IPs value | +| `tvTransport` | TextView | Transport protocol | +| `cardPorts` | MaterialCardView | Port Forwarding card | +| `portForwardContainer` | LinearLayout | Dynamic port forward items | +| `cardLog` | MaterialCardView | Log card | +| `tvLogScrollView` | ScrollView | Log scroll container | +| `tvLog` | TextView | Log text | +| `btnImportConfig` | MaterialButton | Import config button | +| `btnToggleLog` | MaterialButton | Log toggle button | +| `btnSettings` | ImageButton | Settings icon | + +## Breaking Changes + +- `tvServerEndpoint` renamed to `tvAdminWeb` — any code referencing the old ID must be updated +- New string resources added — translations needed for non-Indonesian locales