2025年2月12日 星期三

Docker 開發筆記 - 從 Gitlab 觸發 CI/CD,製作 Docker Image 並發布到 Amazon Elastic Container Service (AWS ECS)


以前常做的事是自行弄個 Jenkins 在搭配 Ansible 連續技完成 CI/CD 的任務,今年要來推廣 Docker 生態,把簡單的任務包裝成 Docker 降低人員維護的成本,在此挑選 Gitlab.com 作為出發點,接著會使用 Gitlab CI/CD 發布到 Amazon Elastic Container Service。這篇筆記不限於 Laravel framework ,但以他當作範例來進行。
首先就是弄個 gitlab project,可以是 Create blank project 也可以是 Create from template,接著就到 Settings -> CI/CD 欣賞一下有哪些設定選項

General pipelines
Auto DevOps
Runners
Artifacts
Variables
Pipeline trigger tokens
Deploy freezes
Job token permissions
Secure files

在 General pipelines 介面上,可以看到滿多設定的,但整體上起源是在 "Deploy to AWS from GitLab CI/CD" 這篇文章中,整個精髓是在 .gitlab-ci.yml 檔案上,因此可以參考 "Create from template" 的素材,當下可以按 preview 先欣賞一下,多參考幾個就有感覺了:

簡言之 .gitlab-ci.yml 就是定義想做的指令,並且可以指定要用哪個環境(docker image)來運行這些指令。例如 Laravel 範例使用的 image 是 edbizarro/gitlab-ci-pipeline-php:latest 位置,對應的就是在 https://hub.docker.com/r/edbizarro/gitlab-ci-pipeline-php 和 https://github.com/edbizarro/gitlab-ci-pipeline-php ,搞懂後就來建立自己的 .gitlab-ci.yml 檔案,目標是直接依照專案內的 Dockerfile 來編譯 Docker Images:

variables:
  DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT: "myapp-php-fpm"
  DOCKER_IMAGE_NAME_FOR_NGINX: "myapp-nginx"
  DOCKER_TLS_CERTDIR: "/certs"

stages:
  - build

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  script:
    - ls -la $DOCKER_TLS_CERTDIR 
    - docker info
    - docker build -t $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT:latest -f docker/php-fpm/Dockerfile .
    - docker build -t $DOCKER_IMAGE_NAME_FOR_NGINX:latest -f docker/nginx/Dockerfile .
    - docker images
    - pwd
    - ls -la
    - docker save $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT:latest > $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT.image.tar
    - docker save $DOCKER_IMAGE_NAME_FOR_NGINX:latest > $DOCKER_IMAGE_NAME_FOR_NGINX.image.tar
    - ls -la
  artifacts:
    paths:
      - $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT.image.tar
      - $DOCKER_IMAGE_NAME_FOR_NGINX.image.tar
    expire_in: 1 week
  rules:
    - changes:
        - Dockerfile
        - docker/**/*
    - if: '$CI_PIPELINE_SOURCE == "web"'
    - if: '$CI_PIPELINE_SOURCE == "api"'
  #tags:
  #  - docker


如此,回到 Gitlab Project 左邊的 Build 項目,就可以在 Pipelines 去觸發 New pipeline 來觸發 Build docker images ,並且會把產出的 *.image.tar 壓縮起來供下載。此外,在 Build 內的 Pipeline editor 也是滿好用的架構,可以線上編輯 .gitlab-ci.yml 檔案內容,還可以驗證格式內容是否有錯誤。

接下來又做更多事,像是 Laravel framework 相關專案設定和初始化(包括 npm install && npm run build && php artisan optimize):

variables:
  DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT: "myapp-php-fpm"
  DOCKER_IMAGE_NAME_FOR_NGINX: "myapp-nginx"
  DOCKER_TLS_CERTDIR: "/certs"

stages:
  - prepare
  - build

composer:
  stage: prepare
  image: php:8.4-cli
  before_script:
    - apt-get update && apt-get install -y unzip libzip-dev
    - docker-php-ext-install zip
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
  script:
    # https://gitlab.com/gitlab-org/project-templates/laravel/-/blob/main/.gitlab-ci.yml?ref_type=heads
    - cd www
    - composer install --no-dev --optimize-autoloader
    #- php artisan optimize:clear
    - php artisan optimize
  artifacts:
    paths:
      - www/
    expire_in: 1 day
  rules:
    - changes:
        - www/**/*
    - if: '$CI_PIPELINE_SOURCE == "web"'
    - if: '$CI_PIPELINE_SOURCE == "api"'

npm:
  stage: prepare
  image: node:22
  dependencies:
    - composer
  script:
    - cd www
    - npm install
    - npm run build
  artifacts:
    paths:
      - www/node_modules/
      - www/public/build/
    expire_in: 1 day
  rules:
    - changes:
        - www/**/*
    - if: '$CI_PIPELINE_SOURCE == "web"'
    - if: '$CI_PIPELINE_SOURCE == "api"'

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  dependencies:
    - composer
    - npm
  script:
    - docker info
    - docker build -t $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT:latest -f docker/php-fpm/Dockerfile .
    - docker build -t $DOCKER_IMAGE_NAME_FOR_NGINX:latest -f docker/nginx/Dockerfile .
    - docker save $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT:latest > $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT.image.tar
    - docker save $DOCKER_IMAGE_NAME_FOR_NGINX:latest > $DOCKER_IMAGE_NAME_FOR_NGINX.image.tar
  artifacts:
    paths:
      - $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT.image.tar
      - $DOCKER_IMAGE_NAME_FOR_NGINX.image.tar
    expire_in: 1 week
  rules:
    - changes:
        - Dockerfile
        - docker/**/*
        - www/**/*  # 加入 www 目錄的變更觸發
    - if: '$CI_PIPELINE_SOURCE == "web"'
    - if: '$CI_PIPELINE_SOURCE == "api"'

最後,再來增加把 Docker images 發布到 AWS ECS ,目前應當有兩招可以發布:

先試試看 Gitlab Container Registry ,過程就是在 Build stage 中,增加登入到 Gitlab Container Registry 機制:

before_script:
  - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin

實際 Job 運行會看到這段資訊:

$ echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin
WARNING! Your password will be stored unencrypted in /root/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credential-stores
Login Succeeded

接著再把這些串起來,在 build stage 時,就把產生的 docker image 送進 Gitlab Container Registry,並且在 Gitlab Project -> Deploy -> Container Registry 看到成果:

variables:
  DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT: "myapp-php-fpm"
  DOCKER_IMAGE_NAME_FOR_NGINX: "myapp-nginx"
  DOCKER_TLS_CERTDIR: "/certs"
  PHP_IMAGE_PATH: "$CI_REGISTRY_IMAGE/$DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT"
  NGINX_IMAGE_PATH: "$CI_REGISTRY_IMAGE/$DOCKER_IMAGE_NAME_FOR_NGINX"

stages:
  - prepare
  - build

composer:
  stage: prepare
  image: php:8.4-cli
  before_script:
    - apt-get update && apt-get install -y unzip libzip-dev
    - docker-php-ext-install zip
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
  script:
    # https://gitlab.com/gitlab-org/project-templates/laravel/-/blob/main/.gitlab-ci.yml?ref_type=heads
    - cd www
    - composer install --no-dev --optimize-autoloader
    #- php artisan optimize:clear
    - php artisan optimize
  artifacts:
    paths:
      - www/
    expire_in: 1 day
  rules:
    - changes:
        - www/**/*
    - if: '$CI_PIPELINE_SOURCE == "web"'
    - if: '$CI_PIPELINE_SOURCE == "api"'

npm:
  stage: prepare
  image: node:22
  dependencies:
    - composer
  script:
    - cd www
    - npm install
    - npm run build
  artifacts:
    paths:
      - www/node_modules/
      - www/public/build/
    expire_in: 1 day
  rules:
    - changes:
        - www/**/*
    - if: '$CI_PIPELINE_SOURCE == "web"'
    - if: '$CI_PIPELINE_SOURCE == "api"'

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  dependencies:
    - composer
    - npm
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin
  script:
    - docker info
    - docker build -t $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT:latest -f docker/php-fpm/Dockerfile .
    - docker build -t $DOCKER_IMAGE_NAME_FOR_NGINX:latest -f docker/nginx/Dockerfile .
    - docker save $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT:latest > $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT.image.tar
    - docker save $DOCKER_IMAGE_NAME_FOR_NGINX:latest > $DOCKER_IMAGE_NAME_FOR_NGINX.image.tar
    - docker tag $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT:latest $PHP_IMAGE_PATH:$CI_COMMIT_SHA
    - docker tag $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT:latest $PHP_IMAGE_PATH:latest
    - docker tag $DOCKER_IMAGE_NAME_FOR_NGINX:latest $NGINX_IMAGE_PATH:$CI_COMMIT_SHA
    - docker tag $DOCKER_IMAGE_NAME_FOR_NGINX:latest $NGINX_IMAGE_PATH:latest
    - docker push $PHP_IMAGE_PATH:$CI_COMMIT_SHA
    - docker push $PHP_IMAGE_PATH:latest
    - docker push $NGINX_IMAGE_PATH:$CI_COMMIT_SHA
    - docker push $NGINX_IMAGE_PATH:latest
  artifacts:
    paths:
      - $DOCKER_IMAGE_NAME_FOR_LARAVEL_PROJECT.image.tar
      - $DOCKER_IMAGE_NAME_FOR_NGINX.image.tar
    expire_in: 1 week
  rules:
    - changes:
        - Dockerfile
        - docker/**/*
        - www/**/*  # 加入 www 目錄的變更觸發
    - if: '$CI_PIPELINE_SOURCE == "web"'
    - if: '$CI_PIPELINE_SOURCE == "api"'

如此就只需要到 AWS ECS 後台去建立 Task 改從 Gitlab Container Registry 取資料即可完成發佈,這個架構的優勢是避免在 Gitlab.com 關聯太多 AWS 存取方式,也可以避免 AWS 相關資訊外洩,若要更加保護的話,可以連 Gitlab CI/CD 的 Runner 都挑自己的機器會更穩。



最後,還有一點要留意的,對 Gitlab.com 免費服務有 5GB 空間的限制,假設 Docker Image 隨便就佔了幾百GB,那發佈幾次後就會沒空間了,因此,再補上一段只保留最後三版 Docker Image 的機制:

cleanup_images:
  stage: cleanup
  image: docker:latest
  variables:
    GIT_STRATEGY: none
  before_script:
    - apk add --no-cache curl jq
    - echo "CI_API_V4_URL = ${CI_API_V4_URL}"
    - echo "CI_PROJECT_ID = ${CI_PROJECT_ID}"
    # https://docs.gitlab.com/ee/api/container_registry.html
    - echo "CI_REGISTRY_PASSWORD = ${CI_REGISTRY_PASSWORD}"
    - echo "CI_JOB_TOKEN = ${CI_JOB_TOKEN}" 
    - echo "API REQUEST URL = ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repositories"
    - |
      curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repositories" | jq '.' 
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - |
      REGISTRY_TARGET=$(curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repositories" | jq -r '. | if length > 0 then .[].id else empty end')
      for REGISTRY_ID in $REGISTRY_TARGET; do
        echo "REGISTRY_ID = $REGISTRY_ID"
        curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repositories/${REGISTRY_ID}/tags"
        REGISTRY_LAST_TAG=$(curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repositories/${REGISTRY_ID}/tags" | jq -r '[ .[] | select(.name != "latest") ] | sort_by(.name) | if length > 3 then .[:-3] | .[].name else empty end')
        for REGISTRY_TAG in $REGISTRY_LAST_TAG; do
          echo "REGISTRY_ID = $REGISTRY_ID, REGISTRY_TAG = $REGISTRY_TAG"
          curl --request DELETE  --header "JOB-TOKEN: ${CI_JOB_TOKEN}" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repositories/${REGISTRY_ID}/tags/${REGISTRY_TAG}"
        done
      done
  rules:
    - when: on_success

主要是透過 CI_JOB_TOKEN 跟 Gitlab API 溝通,像是取出該專案下有幾個 Container Registry ,並且善用 docker tag 時,把 tag prefix 增加 timestamp ,方便此時 sorting 並挑選舊的 tag 且移除他們,如此就可以節省 Gitlab Project 使用空間。

至於在 AWS ECS 的設定,就主要先看 Gitlab 跟 AWS ECS 官方文件了,由於 Docker Image 我們擺在 Gitlab.com,這時 AWS ECS -> Cluster -> Task ,在定義 Container 要從哪邊 Container Registry 取出時,要寫入 registry.gitlab.com/YourGitlabGroup/YourGitlabProject/YourContainerRegistryProject:latest 等類似的位置,下一刻則是 AWS ECS 運行時,此時 ecsTaskExecutionRole 怎麼存取的議題

流程:
  1. 在 Gitlab Project 上,先建立一個 Deploy Token,會拿到一個帳號跟密碼
    • Settings -> Repository -> Deploy tokens -> Add token
  2. 在 AWS Secrets Manager 上,添加一組,選擇其他類型項目即可


    • 假設產出是 arn:aws:secretsmanager:ap-northeast-1:#####:secret:gitlab.com-container-registry
  3. 在 AWS Identity and Access Management (IAM) 上,找到 ecsTaskExecutionRole ,幫它添加可以存取 Secrets Manager 的權限
    • 開放權限可以只提供 READOnly ,甚至細微到只能對 arn:aws:secretsmanager:ap-northeast-1:#####:secret:gitlab.com-container-registry 操作
    • 沒設定好的錯誤訊息:
      • ResourceInitializationError: unable to pull secrets or registry auth: execution resource retrieval failed: unable to get registry auth from asm: service call has been retried 1 time(s): failed to fetch secret arn:aws:secretsmanager:ap-northeast-1:#####:secret:gitlab.com-container-registry from secrets manager: AccessDeniedException: User: arn:aws:sts::#####:assumed-role/ecsTaskExecutionRole/##### is not authorized to perform: secretsmanager:GetSecretValue on resource: arn:aws:secretsmanager:ap-northeast-1:#####:secret:gitlab.com-container-registry because no identity-based policy allows the secretsmanager:GetSecretValue action status code: 400
    • 設定範例 JSON
      • {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Sid": "VisualEditor0",
                    "Effect": "Allow",
                    "Action": [
                        "secretsmanager:GetRandomPassword",
                        "secretsmanager:GetResourcePolicy",
                        "secretsmanager:GetSecretValue",
                        "secretsmanager:DescribeSecret",
                        "secretsmanager:ListSecretVersionIds",
                        "secretsmanager:ListSecrets",
                        "secretsmanager:BatchGetSecretValue"
                    ],
                    "Resource": "*"
                }
            ]
        }
  4. 在 AWS ECS 的 Task 就可以好好設定 Container 的存取機制
    • 私有登錄檔 -> Secrets Manager ARN 或名稱 -> arn:aws:secretsmanager:ap-northeast-1:#####:secret:gitlab.com-container-registry
如此在 AWS ECS -> Cluster -> Create Service 時,就會觸發 AWS ECS Task 運行,就可以看到 Docker Container 被創立出來

目前研究先告一個段落,在 AWS ECS 的設置規劃其實也要不小的篇幅的。

再碎碎念一下,直接實驗 AWS ECS 直接去跟 Gitlab.com Container Registry 要資料時,有時會出現彷彿無法正常取出的問題,這個我還不是很清楚排除的方式,目前還有偷懶隔天再弄一次,一樣的方式又正常可通過 Orz 或許最佳還是改用 AWS ECS Private Container Registry 維護,這樣服務發布架構可以省去一些不穩定的額外煩惱。

類似錯誤訊息:
  • esourceInitializationError: unable to pull secrets or registry auth: unable to get registry auth from asm: There is a connection issue between the task and AWS Secrets Manager. 
數個資安提問:
  • 假設你的 Docker Images 是在 Gitlab.com 產出,要 docker push 到 AWS ECS Container Registry 時,就會要面對在 Gitlab.com 上暴露 AWS 存取的資訊
  • 假設 Build docker images 是用 Gitlab.com 機器(Runner),對應的也是有敏感資訊會在這些機器上,你是否信任他們?
  • 假設 Build docker images 時,用了其他人製作的 Docker images ,你的敏感資訊是否有妥善保護?惡意的 docker images 可以在啟動或結束時,把用戶的敏感資訊傳遞出去
ref: 

2024年12月17日 星期二

Node.js 開發筆記 - 使用 Electron + Vite + Vue 開發一款 PC app ,可支援 mDNS 裝置搜尋 @ macOS 15.2



有點久沒用 Electron 開發 PC app ,協助同事從旁實驗一下新的架構,這次研究後,直接用 electron-vite 初始化專案,核心就是 Electron + Vite 的研發環境,前台是 Vue 前端畫面。原先在想是不是一律自行安裝套件且故意都裝最新版,想完後還是縮一下,回歸別人整理好的工具,而這次強調用 Vite 整合,就不從 electron-vue 開始。

開發環境:

% sw_vers 
ProductName: macOS
ProductVersion: 15.2
BuildVersion: 24C101
% nvm use --lts         
Now using node v22.12.0 (npm v10.9.0)

流水帳:

```
% npm create electron-vite@latest
Need to install the following packages:
create-electron-vite@0.7.1
Ok to proceed? (y) y


> npx
> create-electron-vite

✔ Project name: … my-electron-app
✔ Project template: › Vue

Scaffolding project in /private/tmp/my-electron-app...

Done. Now run:

  cd my-electron-app
  npm install
  npm run dev

npm notice
npm notice New major version of npm available! 10.9.0 -> 11.0.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.0.0
npm notice To update run: npm install -g npm@11.0.0
npm notice

% cd my-electron-app
my-electron-app % npm install express multicast-dns axios
my-electron-app % cat package.json 
{
  "name": "my-electron-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build && electron-builder",
    "preview": "vite preview"
  },
  "dependencies": {
    "axios": "^1.7.9",
    "express": "^4.21.2",
    "multicast-dns": "^7.2.5",
    "vue": "^3.4.21"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.4",
    "electron": "^30.0.1",
    "electron-builder": "^24.13.3",
    "typescript": "^5.2.2",
    "vite": "^5.1.6",
    "vite-plugin-electron": "^0.28.6",
    "vite-plugin-electron-renderer": "^0.14.5",
    "vue-tsc": "^2.0.26"
  },
  "main": "dist-electron/main.js"
}
my-electron-app % npm run dev
```

如此就有了一個畫面,而目錄結構:

```
% tree -I 'node_modules|dist|build'
.
├── README.md
├── dist-electron
│   ├── main.js
│   └── preload.mjs
├── electron
│   ├── electron-env.d.ts
│   ├── main.ts
│   └── preload.ts
├── electron-builder.json5
├── index.html
├── package-lock.json
├── package.json
├── public
│   ├── electron-vite.animate.svg
│   ├── electron-vite.svg
│   └── vite.svg
├── src
│   ├── App.vue
│   ├── assets
│   │   └── vue.svg
│   ├── components
│   │   └── HelloWorld.vue
│   ├── main.ts
│   ├── style.css
│   └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

7 directories, 22 files
```

接著,要來擴充一下功能,讓這支程式可以使用 mDNS 協定偵測環境,這功能需實作在 Electron 架構上,在 main.ts 添加裝置搜尋:

```
$ cat electron/main.ts 
...

import mdns from 'multicast-dns'

...

// 使用一個全域變數紀錄偵測到的 mDNS 裝置,並記錄他初次出現的時間,也紀錄最後一次看到的時間
const mdnsDevices: { [key: string]: { name: string, type: string, firstSeen: number, lastSeen: number, data: any[], answers: any[] } } = {}

function startMdnsQuery() {
  const mdnsInstance = mdns()

  console.log('mDNS Query Start...')

  // 修改 name & type
  // dns-sd -B _services._dns-sd._udp local
  mdnsInstance.query({
    questions: [{
      //name: '_http._tcp.local',
      name: '_services._dns-sd._udp.local',
      type: 'PTR'
    }]
  })

  mdnsInstance.on('response', (response: { answers: string | any[] }) => {
    console.log('mDNS Response:', response)

    if (win && response.answers.length > 0) {
      for (const answer of response.answers) {
        try {
          // 建立一個 unique key 來代表一個裝置
          const key = `${answer.name}-${answer.type}`
          if (!mdnsDevices[key]) {
            mdnsDevices[key] = {
              name: answer.name,
              type: answer.type,
              data: [answer.data],
              firstSeen: Date.now(),
              lastSeen: Date.now(),
              answers: [answer]
            }
            console.log('ADD Device:')
            console.log(mdnsDevices[key])
          } else {
            mdnsDevices[key].lastSeen = Date.now()
            mdnsDevices[key].answers.push(answer)
            if (!mdnsDevices[key].data.includes(answer.data)) {
              mdnsDevices[key].data.push(answer.data)
            }
          }
          win.webContents.send('mdns-found', mdnsDevices)
        } catch (error) {
          console.error(error)
        }
      }
    }
  })
}
...

app.whenReady().then(() => {
  createWindow()
  startMdnsQuery()
})
```

在 preload.ts 增加資料回傳到前端網頁上:

```
$ cat electron/preload.ts 
...
contextBridge.exposeInMainWorld('mdnsAPI', {
  onFound: (callback: (devices: any[]) => void) => {
    ipcRenderer.on('mdns-found', (_event, devices) => {
      callback(devices)
    })
  }
})
```

App.vue:

```
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'

import { ref, onMounted } from 'vue'

const devices = ref<any[]>([])
onMounted(() => {
  // 若在 preload 中已暴露 mdnsAPI
  if (window.mdnsAPI && typeof window.mdnsAPI.onFound === 'function') {
    window.mdnsAPI.onFound((foundDevices) => {
      // 將接收到的裝置資料放入響應式變數
      devices.value = foundDevices
      console.log('Found devices:')
      console.log(foundDevices)
    })
  }
})
</script>

<template>
  <div>
    <a href="https://electron-vite.github.io" target="_blank">
      <img src="/electron-vite.svg" class="logo" alt="Vite logo" />
    </a>
    <a href="https://vuejs.org/" target="_blank">
      <img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
    </a>
  </div>
  <!--
  <HelloWorld msg="Vite + Vue" />
  <hr />
  -->
  <div>
    <h3>已搜尋到的裝置:</h3>
    <ul id="deviceList">
      <li v-for="(device, index) in devices" :key="index">
        [{{ new Date(device.firstSeen).toLocaleString('zh-TW', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) }}] {{ device.name }} ({{ device.type }}) 
        <ul>
          <li v-for="(data, i) in device.data" :key="I">
            {{ data }}
          </li>
        </ul>
      </li>
    </ul>
  </div>
</template>

<style scoped>
.logo {
  height: 6em;
  padding: 1.5em;
  will-change: filter;
  transition: filter 300ms;
}
.logo:hover {
  filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
  filter: drop-shadow(0 0 2em #42b883aa);
}

#deviceList {
  text-align: left;
  list-style-type: none;
  padding: 0;
}
</style>
```

收工 ,有空再來做些什麼應用吧:github.com/changyy/study-my-electron-app

2024年12月5日 星期四

PHP 開發筆記 - 產生 CountryCode, CountryName, CityName, GeoLocation 列表

原本一直盧 AI 產出,發現產出的品質很難控,最後就轉個彎去把 GeoIP DB 內的資料輸出即可,而策略也很簡單,單純用 IP 暴力輪詢。理論上可以優雅一點去了解 DB Record format,總之,暴力解也很快,就順手先記錄一下,此例僅列出部分資訊(非輪詢所有 IPv4):

```
<?php
if (!extension_loaded('geoip')) {
    die("GeoIP extension is not installed\n");
}

class GeoIPParser {
    private $dbPath;
    
    public function __construct($dbPath = '/tmp/db.dat') {
        $this->dbPath = $dbPath;
        geoip_setup_custom_directory('/tmp');
    }
    
    public function parse() {
        if (!file_exists($this->dbPath)) {
            throw new Exception("GeoIP City database not found at {$this->dbPath}");
        }
        
        $locations = [];
        $processed = [];
        
        try {
            // 遍歷 IP 範圍,每個 /16 subnet 取樣幾個 IP
            for ($first = 1; $first <= 255; $first++) {
                fprintf(STDERR, "\rProcessing IP block: %d/255", $first);
                
                for ($second = 0; $second <= 255; $second += 5) {
                    $ip = "$first.$second.1.1";
                    $record = geoip_record_by_name($ip);
                    
                    if ($record && 
                        !empty($record['country_code']) && 
                        !empty($record['city'])) {
                        
                        $key = $record['country_code'] . '|' . $record['city'];
                        
                        if (!isset($processed[$key])) {
                            $location = [
                                'country_code' => $record['country_code'],
                                'country_name' => $record['country_name'],
                                'city_name' => $record['city'],
                                'geo_location' => [
                                    'latitude' => round($record['latitude'], 4),
                                    'longitude' => round($record['longitude'], 4)
                                ]
                            ];
                            
                            $locations[] = $location;
                            $processed[$key] = true;
                        }
                    }
                }
            }
            
            fprintf(STDERR, "\nProcessing completed. Total locations found: " . count($locations) . "\n");
            
            // 按國家代碼和城市名稱排序
            usort($locations, function($a, $b) {
                $countryComp = strcmp($a['country_code'], $b['country_code']);
                return $countryComp === 0 ? 
                    strcmp($a['city_name'], $b['city_name']) : 
                    $countryComp;
            });
            
            return $locations;
        } catch (Exception $e) {
            fprintf(STDERR, "Error during processing: " . $e->getMessage() . "\n");
            throw $e;
        }
    }
}

try {
    $parser = new GeoIPParser();
    $locations = $parser->parse();
    
    // 輸出為格式化的 JSON
    echo json_encode($locations, 
        JSON_PRETTY_PRINT | 
        JSON_UNESCAPED_UNICODE | 
        JSON_UNESCAPED_SLASHES
    );
    
} catch (Exception $e) {
    fwrite(STDERR, "Error: " . $e->getMessage() . "\n");
    exit(1);
}
```

產出:

```
% php83 -ini | grep geoip
/opt/local/var/db/php83/geoip.ini,
geoip
geoip support => enabled
geoip extension version => 1.1.1
geoip library version => 1005000
geoip.custom_directory => no value => no value
% php83 geo-lookup.php 
Processing IP block: 255/255
Processing completed. Total locations found: 2657
...
```

片段資料: 

% php83 list-geo-city.php | jq '[.[]|select(.country_code=="TW")]'         
Processing IP block: 255/255
Processing completed. Total locations found: 8020
[
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Anping District",
    "geo_location": {
      "latitude": 22.9965,
      "longitude": 120.1617
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Bade District",
    "geo_location": {
      "latitude": 24.9259,
      "longitude": 121.2763
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Banqiao",
    "geo_location": {
      "latitude": 25.0104,
      "longitude": 121.4683
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Beitou",
    "geo_location": {
      "latitude": 25.1403,
      "longitude": 121.4948
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Chang-hua",
    "geo_location": {
      "latitude": 24.0759,
      "longitude": 120.5657
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Chiayi City",
    "geo_location": {
      "latitude": 23.4815,
      "longitude": 120.4498
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Chiyayi County",
    "geo_location": {
      "latitude": 23.4461,
      "longitude": 120.5728
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Daan",
    "geo_location": {
      "latitude": 25.0316,
      "longitude": 121.5345
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Dacun",
    "geo_location": {
      "latitude": 23.9978,
      "longitude": 120.547
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Dawan",
    "geo_location": {
      "latitude": 23.2073,
      "longitude": 120.1906
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Daya",
    "geo_location": {
      "latitude": 24.2226,
      "longitude": 120.6493
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Douliu",
    "geo_location": {
      "latitude": 23.7125,
      "longitude": 120.545
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "East District",
    "geo_location": {
      "latitude": 22.9721,
      "longitude": 120.2224
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Guishan",
    "geo_location": {
      "latitude": 25.0273,
      "longitude": 121.359
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Hsinchu",
    "geo_location": {
      "latitude": 24.8065,
      "longitude": 120.9706
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Hsinchu County",
    "geo_location": {
      "latitude": 24.673,
      "longitude": 121.1614
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Hualien City",
    "geo_location": {
      "latitude": 23.9807,
      "longitude": 121.6115
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Jian",
    "geo_location": {
      "latitude": 23.9516,
      "longitude": 121.5639
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Jinshan",
    "geo_location": {
      "latitude": 25.0613,
      "longitude": 121.5705
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Kaohsiung City",
    "geo_location": {
      "latitude": 22.6148,
      "longitude": 120.3139
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Keelung",
    "geo_location": {
      "latitude": 25.1322,
      "longitude": 121.742
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Linkou District",
    "geo_location": {
      "latitude": 25.0738,
      "longitude": 121.3935
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Miaoli",
    "geo_location": {
      "latitude": 24.5641,
      "longitude": 120.8275
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Nantou City",
    "geo_location": {
      "latitude": 23.9082,
      "longitude": 120.6558
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Neihu District",
    "geo_location": {
      "latitude": 25.0811,
      "longitude": 121.5838
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "New Taipei City",
    "geo_location": {
      "latitude": 24.9466,
      "longitude": 121.586
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Penghu County",
    "geo_location": {
      "latitude": 23.5748,
      "longitude": 119.6098
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Pingtung City",
    "geo_location": {
      "latitude": 22.6745,
      "longitude": 120.491
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Puli",
    "geo_location": {
      "latitude": 23.9678,
      "longitude": 120.9644
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Sanchong District",
    "geo_location": {
      "latitude": 25.0691,
      "longitude": 121.4878
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Sanxia District",
    "geo_location": {
      "latitude": 24.9336,
      "longitude": 121.372
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Shiding District",
    "geo_location": {
      "latitude": 24.9956,
      "longitude": 121.6546
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Shoufeng",
    "geo_location": {
      "latitude": 23.8341,
      "longitude": 121.521
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taibao",
    "geo_location": {
      "latitude": 23.4603,
      "longitude": 120.3284
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taichung",
    "geo_location": {
      "latitude": 24.144,
      "longitude": 120.6844
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taichung City",
    "geo_location": {
      "latitude": 24.1547,
      "longitude": 120.6716
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Tainan City",
    "geo_location": {
      "latitude": 22.9917,
      "longitude": 120.2147
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taipei",
    "geo_location": {
      "latitude": 25.0504,
      "longitude": 121.5324
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taitung",
    "geo_location": {
      "latitude": 22.7563,
      "longitude": 121.1418
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taoyuan",
    "geo_location": {
      "latitude": 24.9977,
      "longitude": 121.2965
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Taoyuan District",
    "geo_location": {
      "latitude": 24.9889,
      "longitude": 121.3175
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Xizhi District",
    "geo_location": {
      "latitude": 25.0696,
      "longitude": 121.6577
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Yilan",
    "geo_location": {
      "latitude": 24.7574,
      "longitude": 121.7421
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Yongkang District",
    "geo_location": {
      "latitude": 23.0204,
      "longitude": 120.2591
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Yunlin",
    "geo_location": {
      "latitude": 23.7113,
      "longitude": 120.3897
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Zhongli District",
    "geo_location": {
      "latitude": 24.9614,
      "longitude": 121.2437
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Zhubei",
    "geo_location": {
      "latitude": 24.8351,
      "longitude": 121.0056
    }
  },
  {
    "country_code": "TW",
    "country_name": "Taiwan",
    "city_name": "Zuoying",
    "geo_location": {
      "latitude": 22.6868,
      "longitude": 120.2971
    }
  }
]

2024年12月4日 星期三

PHP 開發筆記 - 關於 DST 日光節約時間的判斷邏輯與實現方式

這是一個很古老的議題,但因為產品要進行本地化資訊顯示開始提供一些架構,然而,這本身顯示時間上都已經很成熟也有內建 library 可用,但基於一些嵌入式產品的條件,如連網狀態等,需要復刻一版服務出來,像是把日光節約時間的邏輯給列出來等。

輕鬆問問 Claude.AI 就立馬有了一版,且過程中還發現 AI 採用每日檢查是否是日光節約時間 XD 但明明可以查 tzdata 有效率做完這件事才對。就順手引導 AI ,自己也下去改一下判斷邏輯。

此外,也透過 AI 補足了一些知識,像是 tzdata 會定期更新,且一年也可能更新數次:


隨便下載一份解開來看:
  • zone.tab: 全部時區清單,內有 geo location 資訊
  • northamerica: 北美洲的 DST 規則
  • europe: 歐洲的 DST 規則
  • asia: 亞洲的 DST 規則
  • australasia: 澳洲和亞洲部分地區的 DST 規則
  • africa: 非洲的 DST 規則
  • southamerica: 南美洲的 DST 規則
  • antarctica: 南極洲的 DST 規則
而 abbreviation 縮寫意義:
  • EST = Eastern Standard Time (東部標準時間)
  • EDT = Eastern Daylight Time (東部夏令時間)
  • PST = Pacific Standard Time (太平洋標準時間)
  • PDT = Pacific Daylight Time (太平洋夏令時間)
  • CST = Central Standard Time (中部標準時間)
  • CDT = Central Daylight Time (中部夏令時間)
最後,用 PHP Code 順一個出來,這邊 dst_begin 不是日光節約時間真正的起始日,僅是一個趨近資料,只有當該時區不是日光節約時間時,才會是下一個日光節約時段開始的時間

```
<?php
$output = [];

$dst_query_start = strtotime("-7 day midnight");
$days_in_year = date('L') ? 366 + 7 : 365 + 7; // 檢查是否為閏年
$dst_query_end = $dst_query_start + (86400 * $days_in_year);
try {
foreach(DateTimeZone::listIdentifiers() as $tz) {
$datetime = new DateTime('now', new DateTimeZone($tz));
$timezone = new DateTimeZone($tz);
$location = $timezone->getLocation();

$transitions = $timezone->getTransitions($dst_query_start, $dst_query_end);

$has_dst = false;
$dst_begin = array( 'datetime' => NULL, 'timestamp' => NULL, 'offset' => NULL, 'abbreviation' => NULL);
$dst_end = array( 'datetime' => NULL, 'timestamp' => NULL, 'offset' => NULL, 'abbreviation' => NULL);

for ($i=0, $cnt=count($transitions) ; $i < $cnt ; ++$i ) {
if ($has_dst == false) {
if ($transitions[$i]['isdst']) {
$has_dst = true;
$dst_begin = array(
'datetime' => date('Y-m-d H:i:s', $transitions[$i]['ts']),
'timestamp' => $transitions[$i]['ts'],
'offset' => $transitions[$i]['offset'],
'abbreviation' => $transitions[$i]['abbr'],
);
}
} else if (!$transitions[$i]['isdst']) {
$dst_end = array(
'datetime' => date('Y-m-d H:i:s', $transitions[$i]['ts']),
'timestamp' => $transitions[$i]['ts'],
'offset' => $transitions[$i]['offset'],
'abbreviation' => $transitions[$i]['abbr'],
);
break;
}
}

array_push($output, array(
'timezone' => $tz,
'offset' => $datetime->format('P'),
'abbreviation' => $datetime->format('T'),
'is_dst' => (bool)$datetime->format('I'),
'dst_info' => array(
'begin' => $dst_begin,
'end' => $dst_end,
'has_dst' => $has_dst,
),
'country_code' => isset($location['country_code']) && $location['country_code'] != '??' ? $location['country_code'] : null,
'latitude' => isset($location['latitude']) ? $location['latitude'] : null,
'longitude' => isset($location['longitude']) ? $location['longitude']: null,
));
}
} catch (Exception $e) {
$output = $e->getMessage();
}
echo json_encode($output, JSON_PRETTY_PRINT)."\n";
```

在此刻 2024-12-04 運行 PHP Code 結果,透過 jq 來過濾清單,列出哪些有日光節約時段時區:

```
% php83 test.php | jq '[.[] | select(.dst_info.has_dst == true) | {timezone: .timezone, dst_begin: .dst_info.begin.datetime, dst_end: .dst_info.end.datetime}]'
[
  {
    "timezone": "Africa/Cairo",
    "dst_begin": "2025-04-24 22:00:00",
    "dst_end": "2025-10-30 21:00:00"
  },
  {
    "timezone": "Africa/Casablanca",
    "dst_begin": "2025-02-23 02:00:00",
    "dst_end": "2025-04-06 02:00:00"
  },
  {
    "timezone": "Africa/Ceuta",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Africa/El_Aaiun",
    "dst_begin": "2025-02-23 02:00:00",
    "dst_end": "2025-04-06 02:00:00"
  },
  {
    "timezone": "America/Adak",
    "dst_begin": "2025-03-09 12:00:00",
    "dst_end": "2025-11-02 11:00:00"
  },
  {
    "timezone": "America/Anchorage",
    "dst_begin": "2025-03-09 11:00:00",
    "dst_end": "2025-11-02 10:00:00"
  },
  {
    "timezone": "America/Asuncion",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-03-23 03:00:00"
  },
  {
    "timezone": "America/Boise",
    "dst_begin": "2025-03-09 09:00:00",
    "dst_end": "2025-11-02 08:00:00"
  },
  {
    "timezone": "America/Cambridge_Bay",
    "dst_begin": "2025-03-09 09:00:00",
    "dst_end": "2025-11-02 08:00:00"
  },
  {
    "timezone": "America/Chicago",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Ciudad_Juarez",
    "dst_begin": "2025-03-09 09:00:00",
    "dst_end": "2025-11-02 08:00:00"
  },
  {
    "timezone": "America/Denver",
    "dst_begin": "2025-03-09 09:00:00",
    "dst_end": "2025-11-02 08:00:00"
  },
  {
    "timezone": "America/Detroit",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Edmonton",
    "dst_begin": "2025-03-09 09:00:00",
    "dst_end": "2025-11-02 08:00:00"
  },
  {
    "timezone": "America/Glace_Bay",
    "dst_begin": "2025-03-09 06:00:00",
    "dst_end": "2025-11-02 05:00:00"
  },
  {
    "timezone": "America/Goose_Bay",
    "dst_begin": "2025-03-09 06:00:00",
    "dst_end": "2025-11-02 05:00:00"
  },
  {
    "timezone": "America/Grand_Turk",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Halifax",
    "dst_begin": "2025-03-09 06:00:00",
    "dst_end": "2025-11-02 05:00:00"
  },
  {
    "timezone": "America/Havana",
    "dst_begin": "2025-03-09 05:00:00",
    "dst_end": "2025-11-02 05:00:00"
  },
  {
    "timezone": "America/Indiana/Indianapolis",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Indiana/Knox",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Indiana/Marengo",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Indiana/Petersburg",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Indiana/Tell_City",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Indiana/Vevay",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Indiana/Vincennes",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Indiana/Winamac",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Inuvik",
    "dst_begin": "2025-03-09 09:00:00",
    "dst_end": "2025-11-02 08:00:00"
  },
  {
    "timezone": "America/Iqaluit",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Juneau",
    "dst_begin": "2025-03-09 11:00:00",
    "dst_end": "2025-11-02 10:00:00"
  },
  {
    "timezone": "America/Kentucky/Louisville",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Kentucky/Monticello",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Los_Angeles",
    "dst_begin": "2025-03-09 10:00:00",
    "dst_end": "2025-11-02 09:00:00"
  },
  {
    "timezone": "America/Matamoros",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Menominee",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Metlakatla",
    "dst_begin": "2025-03-09 11:00:00",
    "dst_end": "2025-11-02 10:00:00"
  },
  {
    "timezone": "America/Miquelon",
    "dst_begin": "2025-03-09 05:00:00",
    "dst_end": "2025-11-02 04:00:00"
  },
  {
    "timezone": "America/Moncton",
    "dst_begin": "2025-03-09 06:00:00",
    "dst_end": "2025-11-02 05:00:00"
  },
  {
    "timezone": "America/Nassau",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/New_York",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Nome",
    "dst_begin": "2025-03-09 11:00:00",
    "dst_end": "2025-11-02 10:00:00"
  },
  {
    "timezone": "America/North_Dakota/Beulah",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/North_Dakota/Center",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/North_Dakota/New_Salem",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Nuuk",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "America/Ojinaga",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Port-au-Prince",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Rankin_Inlet",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Resolute",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Santiago",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-06 03:00:00"
  },
  {
    "timezone": "America/Scoresbysund",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "America/Sitka",
    "dst_begin": "2025-03-09 11:00:00",
    "dst_end": "2025-11-02 10:00:00"
  },
  {
    "timezone": "America/St_Johns",
    "dst_begin": "2025-03-09 05:30:00",
    "dst_end": "2025-11-02 04:30:00"
  },
  {
    "timezone": "America/Thule",
    "dst_begin": "2025-03-09 06:00:00",
    "dst_end": "2025-11-02 05:00:00"
  },
  {
    "timezone": "America/Tijuana",
    "dst_begin": "2025-03-09 10:00:00",
    "dst_end": "2025-11-02 09:00:00"
  },
  {
    "timezone": "America/Toronto",
    "dst_begin": "2025-03-09 07:00:00",
    "dst_end": "2025-11-02 06:00:00"
  },
  {
    "timezone": "America/Vancouver",
    "dst_begin": "2025-03-09 10:00:00",
    "dst_end": "2025-11-02 09:00:00"
  },
  {
    "timezone": "America/Winnipeg",
    "dst_begin": "2025-03-09 08:00:00",
    "dst_end": "2025-11-02 07:00:00"
  },
  {
    "timezone": "America/Yakutat",
    "dst_begin": "2025-03-09 11:00:00",
    "dst_end": "2025-11-02 10:00:00"
  },
  {
    "timezone": "Antarctica/Macquarie",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 16:00:00"
  },
  {
    "timezone": "Antarctica/McMurdo",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 14:00:00"
  },
  {
    "timezone": "Antarctica/Troll",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Arctic/Longyearbyen",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Asia/Beirut",
    "dst_begin": "2025-03-29 22:00:00",
    "dst_end": "2025-10-25 21:00:00"
  },
  {
    "timezone": "Asia/Famagusta",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Asia/Gaza",
    "dst_begin": "2025-04-12 00:00:00",
    "dst_end": "2025-10-24 23:00:00"
  },
  {
    "timezone": "Asia/Hebron",
    "dst_begin": "2025-04-12 00:00:00",
    "dst_end": "2025-10-24 23:00:00"
  },
  {
    "timezone": "Asia/Jerusalem",
    "dst_begin": "2025-03-28 00:00:00",
    "dst_end": "2025-10-25 23:00:00"
  },
  {
    "timezone": "Asia/Nicosia",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Atlantic/Azores",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Atlantic/Bermuda",
    "dst_begin": "2025-03-09 06:00:00",
    "dst_end": "2025-11-02 05:00:00"
  },
  {
    "timezone": "Atlantic/Canary",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Atlantic/Faroe",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Atlantic/Madeira",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Australia/Adelaide",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 16:30:00"
  },
  {
    "timezone": "Australia/Broken_Hill",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 16:30:00"
  },
  {
    "timezone": "Australia/Hobart",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 16:00:00"
  },
  {
    "timezone": "Australia/Lord_Howe",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 15:00:00"
  },
  {
    "timezone": "Australia/Melbourne",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 16:00:00"
  },
  {
    "timezone": "Australia/Sydney",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 16:00:00"
  },
  {
    "timezone": "Europe/Amsterdam",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Andorra",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Athens",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Belgrade",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Berlin",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Bratislava",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Brussels",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Bucharest",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Budapest",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Busingen",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Chisinau",
    "dst_begin": "2025-03-30 00:00:00",
    "dst_end": "2025-10-26 00:00:00"
  },
  {
    "timezone": "Europe/Copenhagen",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Dublin",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-03-30 01:00:00"
  },
  {
    "timezone": "Europe/Gibraltar",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Guernsey",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Helsinki",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Isle_of_Man",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Jersey",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Kyiv",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Lisbon",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Ljubljana",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/London",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Luxembourg",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Madrid",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Malta",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Mariehamn",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Monaco",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Oslo",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Paris",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Podgorica",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Prague",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Riga",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Rome",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/San_Marino",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Sarajevo",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Skopje",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Sofia",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Stockholm",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Tallinn",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Tirane",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Vaduz",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Vatican",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Vienna",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Vilnius",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Warsaw",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Zagreb",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Europe/Zurich",
    "dst_begin": "2025-03-30 01:00:00",
    "dst_end": "2025-10-26 01:00:00"
  },
  {
    "timezone": "Pacific/Auckland",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 14:00:00"
  },
  {
    "timezone": "Pacific/Chatham",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 14:00:00"
  },
  {
    "timezone": "Pacific/Easter",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-06 03:00:00"
  },
  {
    "timezone": "Pacific/Norfolk",
    "dst_begin": "2024-11-27 00:00:00",
    "dst_end": "2025-04-05 15:00:00"
  }
]
```

2024年11月19日 星期二

Linux 開發筆記 - OpenLDAP StartTLS 憑證驗證與故障排除 @ Ubuntu 20.04


負責 DevOps 同事休假,當初 LDAP 架設沒涉入,用 AI 輔助來練一下功,盡量讓同事好好休假不用 oncall 解題,就把這過程筆記一下。

現在有 AI 輔助服務,做事的心態變了不少,包括:
  1. 面對不是自己從頭到尾參與的計劃時,該怎樣建立自己了解系統思維或是達成想做的任務
  2. AI 的答案也不一定是對的,該如何驗證(如同管理或跨團隊合作上,請夥伴做事,但不一定做對,該怎樣驗正)
事件起因是一些小型內部服務,因 LDAP 憑證失效而無法完成登入(已登入不受影響),我的追法:
  1. 請 AI 給我指令或程式,檢驗快速檢驗 ldap 連線過程,著重在憑證檢查,例如檢查 https 憑證最常就是靠 openssl 指令或是 browser 瀏覽檢視憑證
  2. 有時 AI 會回的很搞剛,開始回寫 code 的 python 解法,這時因為自己有經驗 openssl 指令就能搞定,所以不斷提醒 AI 直接給指令解
  3. 對於 OpenLDAP 不熟的我,開始追問 AI 憑證相關的方向,才順勢了解 StartTLS ,才知道有 ldapsearch, slaptest 指令可用
  4. 對於管理線上服務踩過雷,知道不能隨便 service restart,多問一下 AI 該怎樣先檢驗設定檔,避免機器從 running -> stop -> 設定有誤 -> 服務停止營運
使用 openssl 指令檢查 SSL 憑證,假設網域是 ldap-dmz.changyy.org

```
$ openssl s_client -connect ldap-dmz.changyy.org:443 
...
---
SSL handshake has read xxxx bytes and written xxx bytes
Verification: OK
---
...
```

使用 openssl 指令檢查 LDAP + StartTLS 憑證,假設網域是 ldap-dmz.changyy.org

```
$ openssl s_client -connect ldap-dmz.changyy.org:389 -starttls ldap
...
---
SSL handshake has read xxxx bytes and written xxx bytes
Verification error: certificate has expired
---
...
```

得知 LDAP + StartTLS 使用流程中的憑證過期了,且此例剛好 https 憑證已正常工作,僅 LDAP + StartTLS 的部分失敗,所以很輕鬆的可以把 https 憑證複製一份到 ldap 使用即可。先追蹤設定檔位置:

```
$ sudo cat /etc/ldap/slapd.d/cn\=config.ldif | grep -i TLS
olcTLSCACertificateFile: /etc/ssl/ldap/wildcard.*.ca-bundle
olcTLSCertificateFile: /etc/ssl/ldap/wildcard.*.crt
olcTLSCertificateKeyFile: /etc/ssl/ldap/wildcard.*.key
$ sudo tree -L 1 /etc/ssl/
/etc/ssl/
├── certs
├── ldap
├── nginx
├── openssl.cnf
└── private
```

速速解:

```
$ sudo cp -r /etc/ssl/ldap /etc/ssl/ldap-bak
$ sudo cp /etc/ssl/nginx/wildcard.* /etc/ssl/ldap/
```

檢驗設定檔和重啟服務:

```
$ sudo slaptest
config file testing succeeded
$ sudo service slapd restart
```

再次驗證:

```
$ openssl s_client -connect ldap-dmz.changyy.org:389 -starttls ldap
...
---
SSL handshake has read xxxx bytes and written xxx bytes
Verification: OK
---
...
```

此外,追蹤 LDAP 和使用他的服務時,意外發現不同的服務整合過程,也順手筆記一下,例如 Apache2 設定檔:

僅 LDAP 非加密:

<Location />
    Order allow,deny
    Allow from all
    AuthType basic
    AuthName "service.changyy.org"
    AuthBasicProvider ldap
    AuthLDAPUrl "ldap://ldap-dmz.changyy.org/dc=changyy,dc=org?uid"
    Require valid-user
</Location>

LDAP + StartTLS:

<Location />
    Order allow,deny
    Allow from all
    AuthType basic
    AuthName "service.changyy.org"
    AuthBasicProvider ldap
    AuthLDAPUrl "ldap://ldap-dmz.changyy.org/dc=changyy,dc=org?uid" TLS
    Require valid-user
</Location>

對應的其他服務也會有類似架構,舉例來說 Mantis 也有 mantisbt.org/docs/master/en-US/Admin_Guide/html/admin.config.auth.ldap.html

預設是啟用 LDAP + StartTLS

//
// Determines whether the connection will attempt an opportunistic upgrade to a TLS connection (STARTTLS).
//
// Defaults to ON.
//
// $g_ldap_use_starttls = ON

需要測試 LDAP 健康情況時,可以把它關閉

// $g_ldap_use_starttls = OFF

補充檢查 ldap service 健康情況時,最常是直接使用 ldapsearch 指令:

```
$ sudo apt install ldap-utils
$ ldapsearch -x -H 'ldap://ldap-dmz.changyy.org' -b 'dc=changyy,dc=org'
...
# search result
search: #
result: 0 Success
...
# numResponses: ###
# numEntries: ###
```

測試 LDAP + StartTLS:

```
$ ldapsearch -x -H 'ldap://ldap-dmz.changyy.org' -b 'dc=changyy,dc=org' -Z
...
ldap_start_tls: Connect error (-11)
additional info: (unknown error code)
ldap_result: Can't contact LDAP server (-1)
```

測試 LDAP + StartTLS 需要更多 debug 資訊:

```
$ ldapsearch -x -H 'ldap://ldap-dmz.changyy.org' -b 'dc=changyy,dc=org' -Z -d 1
...
TLS: peer cert untrusted or revoked (0x402)
TLS: can't connect: (unknown error code).
...
ldap_start_tls: Connect error (-11)
...
```

用 PHP Code 嘗試建立連線,先確認 php.ini 套件:

```
php -i | grep ldap
/etc/php/7.4/cli/conf.d/20-ldap.ini,
Protocols => dict, file, ftp, ftps, gopher, http, https, imap, imaps, ldap, ldaps, pop3, pop3s, rtmp, rtsp, scp, sftp, smb, smbs, smtp, smtps, telnet, tftp
ldap
...
```

運行:

```
$ cat /tmp/t.php 
<?php
$ldapconn = ldap_connect("ldap://ldap-dmz.changyy.org");
if ($ldapconn) {
    ldap_set_option($ldapconn, LDAP_OPT_PROTOCOL_VERSION, 3);
    ldap_set_option($ldapconn, LDAP_OPT_REFERRALS, 0);
    // 關閉 TLS
    putenv('LDAPTLS_REQCERT=never');
    
    $bind = ldap_bind($ldapconn);
    if ($bind) {
        echo "LDAP bind successful...\n";
    } else {
        echo "LDAP bind failed...\n";
    }
}
$ php /tmp/t.php 
LDAP bind successful...
```

收工!