2025年4月22日 星期二

Python 開發筆記 - 使用 http.server 建置嵌入式產品網頁開發環境與 Proxy 機制

這個需求快十年前弄過,那時是用 webpack proxy server,可以在習慣的桌機開發網頁,接著對於 CGI 查詢就透過 proxy 導向到 device CGI 

然後,現在弄一個 Python ,方便後續在老 server 上運行,畢竟肥肥的程式碼搭配檔案系統大小寫問題,還是交給 server 檔案系統處理吧,如此,未來有部分 html/js/css code 要快速測試,就只需要到指定機器上,運行

$ python3 proxy-server.py --port 12345 --proxy-target 'http://192.168.123.234:80' --proxy-paths '/cgi-bin/' -d project/path/html/code/
Serving files from directory: project/path/html/code/
Serving HTTP on 0.0.0.0 port 12345 (multi-threaded) ...
Proxying paths ['/cgi-bin/'] to http://192.168.123.234:80
^C
Server stopped.

如此對於 CGI request 就會自動導向到 'http://192.168.123.234:80' 

程式碼純筆記,這都是 AI 產的:

#!/usr/bin/env python3

import http.server
import socketserver
import http.client
import argparse
import os
import sys
import socket
from urllib.parse import urlparse

# 使用 ThreadingMixIn 來處理並發請求
class ThreadedHTTPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
    daemon_threads = True
    allow_reuse_address = True


class ProxyHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
    # 代理目標地址
    proxy_target = None
    # 需要代理的路徑前綴
    proxy_paths = []
    # 連接池(簡單實現)
    conn_pool = {}
    
    def __init__(self, *args, **kwargs):
        # 確保可以正確處理目錄參數
        super().__init__(*args, **kwargs)
    
    def do_GET(self):
        # 檢查是否需要代理這個請求
        for path_prefix in self.proxy_paths:
            if self.path.startswith(path_prefix):
                self.proxy_request()
                return
        
        # 如果不需要代理,則使用默認處理方式
        super().do_GET()
    
    def do_POST(self):
        # 為 POST 請求也提供代理功能
        for path_prefix in self.proxy_paths:
            if self.path.startswith(path_prefix):
                self.proxy_request()
                return
        
        super().do_POST()
    
    def get_connection(self, host, is_ssl=False):
        """從連接池獲取連接,或建立新連接"""
        key = (host, is_ssl)
        
        if key not in self.conn_pool:
            if is_ssl:
                self.conn_pool[key] = http.client.HTTPSConnection(host)
            else:
                self.conn_pool[key] = http.client.HTTPConnection(host)
        
        # 檢查連接是否仍然有效
        conn = self.conn_pool[key]
        try:
            # 嘗試使用一個非阻塞的方式檢查連接狀態
            old_timeout = conn.sock.gettimeout()
            conn.sock.settimeout(0.01)
            conn.sock.recv(1, socket.MSG_PEEK)
            conn.sock.settimeout(old_timeout)
        except (socket.error, AttributeError):
            # 連接已關閉或存在問題,創建新連接
            if is_ssl:
                self.conn_pool[key] = http.client.HTTPSConnection(host)
            else:
                self.conn_pool[key] = http.client.HTTPConnection(host)
        except Exception:
            # 其他類型的錯誤,可能連接仍然有效
            pass
            
        return self.conn_pool[key]
    
    def proxy_request(self):
        """使用 http.client 處理代理請求,這在 Python 3.2 中更高效"""
        target_url = urlparse(self.proxy_target + self.path)
        
        # 獲取請求頭部
        headers = {}
        for key, value in self.headers.items():
            # 排除一些特定的頭部
            if key.lower() not in ('host', 'content-length'):
                headers[key] = value
        
        # 設置正確的 Host 頭部
        headers['Host'] = target_url.netloc
        
        # 讀取請求體(如果有)
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length) if content_length > 0 else None
        
        try:
            # 確定是否使用 HTTPS
            is_ssl = target_url.scheme == 'https'
            
            # 獲取或創建連接
            conn = self.get_connection(target_url.netloc, is_ssl)
            
            # 構建請求路徑
            request_path = target_url.path
            if target_url.query:
                request_path += '?' + target_url.query
            
            # 發送請求
            conn.request(
                method=self.command,
                url=request_path,
                body=body,
                headers=headers
            )
            
            # 獲取響應
            response = conn.getresponse()
            
            # 設置響應狀態碼
            self.send_response(response.status)
            
            # 設置響應頭部
            for header in response.getheaders():
                key, value = header
                if key.lower() != 'transfer-encoding':  # 排除特定頭部
                    self.send_header(key, value)
            self.end_headers()
            
            # 發送響應體
            self.wfile.write(response.read())
            
            # 不關閉連接,將其保留在連接池中
            
        except http.client.HTTPException as e:
            # 處理 HTTP 錯誤
            self.send_response(500)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.end_headers()
            self.wfile.write("HTTP Error: {}".format(str(e)).encode('utf-8'))
            
        except Exception as e:
            # 處理其他錯誤
            self.send_response(500)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.end_headers()
            self.wfile.write("Proxy error: {}".format(str(e)).encode('utf-8'))


def run_server(port=8000, proxy_target="http://localhost:8080", proxy_paths=["/cgi-bin/"], directory=None):
    # 設置代理目標和路徑
    ProxyHTTPRequestHandler.proxy_target = proxy_target
    ProxyHTTPRequestHandler.proxy_paths = proxy_paths
    
    # 確保代理目標是有效的 URL
    if not ProxyHTTPRequestHandler.proxy_target.startswith(('http://', 'https://')):
        ProxyHTTPRequestHandler.proxy_target = "http://{}".format(ProxyHTTPRequestHandler.proxy_target)
    
    # 移除代理目標末尾的斜線(如果有)
    if ProxyHTTPRequestHandler.proxy_target.endswith('/'):
        ProxyHTTPRequestHandler.proxy_target = ProxyHTTPRequestHandler.proxy_target[:-1]
    
    # 設置工作目錄
    if directory:
        os.chdir(directory)
        print("Serving files from directory: {}".format(directory))
    else:
        print("Serving files from current directory: {}".format(os.getcwd()))
    
    # 提高 socket 超時時間以處理慢速連接
    socket.setdefaulttimeout(60)
    
    # 建立服務器(使用線程化服務器)
    server_address = ("", port)
    httpd = ThreadedHTTPServer(server_address, ProxyHTTPRequestHandler)
    
    print("Serving HTTP on 0.0.0.0 port {} (multi-threaded) ...".format(port))
    print("Proxying paths {} to {}".format(proxy_paths, proxy_target))
    
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nServer stopped.")
        httpd.server_close()


if __name__ == "__main__":
    # 解析命令行參數
    parser = argparse.ArgumentParser(description='HTTP Server with proxy capabilities (optimized for Python 3.2)')
    parser.add_argument('--port', type=int, default=8000, help='Port to listen on (default: 8000)')
    parser.add_argument('--proxy-target', type=str, default="http://localhost:8080", 
                        help='Target server to proxy to (default: http://localhost:8080)')
    parser.add_argument('--proxy-paths', type=str, default="/cgi-bin/", 
                        help='Comma-separated list of path prefixes to proxy (default: /cgi-bin/)')
    parser.add_argument('--directory', '-d', type=str, default=None,
                        help='Specify directory to serve files from (default: current directory)')
    
    args = parser.parse_args()
    proxy_paths = [path.strip() for path in args.proxy_paths.split(',')]
    
    run_server(args.port, args.proxy_target, proxy_paths, args.directory)

2025年4月17日 星期四

[macOS] 關閉 Android File Transfer Agent 自動啟動 @ MacBook Air M2 / macOS 15.4.1

雖然 Android File Transfer 差不多被官方捨棄掉了,但我有舊版 Android 開發機還是很依賴它。目前官方不能下載,想下載可參考這篇:macOS - android fileTransfer 下載位置 AndroidFileTransfer.dmg

這篇主要是要記錄關閉 Android File Transfer Agent 的方式,以往都是自己跑去系統位置去稍微破壞一下檔案名稱路徑等等的架構,這次就多了解一下系統架構:
  1. 系統 -> 一般 -> 登入項目與延伸功能 -> 在登入時打開,在此處進行關閉


  2. 把 Android File Transfer Agent 所在處移除
    • ~/Library/Application Support/Google/Android File Transfer/Android File Transfer Agent.app
    • /Applications/Android File Transfer.app/Contents/Helpers/Android File Transfer Agent.app


  3. 如此在應用程式開啟時,就不會重新再次註冊到 "在登入時打開"


這樣可以避免 android phone 插上連接線時,不斷觸發煩人的彈跳視窗(手機或筆電都有),直到需要時,自己再去應用程式開啟 Android File Transfer 就夠了

2025年4月11日 星期五

[macOS] 調整 MacBook screenshot 的畫質,從 png 調整成 jpg 縮小檔案大小 @ MacBook Air 15吋 / Apple M2

關於 MacBook Print Screen 的畫質太高這件事,其實困擾我很久了,以往都是 screenshot 後,自己縮一下圖再傳出去,一週做個不到五次。但如果和同事工作交流更加頻繁時,會懶得縮圖,使得 Mail/Line/Stack 傳的截圖傳遞檔案很大,觀看者也要花更多的時間處理

隨口問問 AI ,可以靠指令調整,像是把預設儲存方式改成 JPG 並調整品質,這樣就搞定了!以 MacBook Air M2 15吋為例,預設整個畫面 screenshot 是落在快 5MB 大小(跟畫面內容複雜度有關),調整後約 1.5MB

我自己就弄成兩個 script 方便開關調整:

% cat reduce-macos-screenshot-quality.sh
#!/bin/bash

defaults read com.apple.screencapture

defaults write com.apple.screencapture type jpg
killall SystemUIServer
defaults write com.apple.screencapture jpg-quality 60

defaults read com.apple.screencapture

% cat reset-macos-screenshot-quality.sh 
#!/bin/bash

defaults read com.apple.screencapture
#defaults write com.apple.screencapture type png

defaults delete com.apple.screencapture
killall SystemUIServer

defaults read com.apple.screencapture

預設的畫質:

% file raw.png 
~/User/Desktop/raw.png: PNG image data, 3420 x 2214, 8-bit/color RGBA, non-interlaced

調整後: 

% file reduce.jpg 
~/User/Desktop/reduce.jpg: JPEG image data, JFIF standard 1.01, aspect ratio, density 144x144, segment length 16, Exif Standard: [TIFF image data, big-endian, direntries=4, xresolution=62, yresolution=70, resolutionunit=2], baseline, precision 8, 3420x2214, components 3

% defaults read com.apple.screencapture
{
    "jpg-quality" = 60;
    type = jpg;
}

收工

其他資訊:

2025年3月7日 星期五

Docker 開發筆記 - 在 Synology NAS 運行 immich 照片服務與 Symbolic link 管理方式 @ Synology DS723+


周邊有強者好友真不錯,時常分享把一堆服務都搞自建方案,目的不是省錢,而是追求資料握在自己手中的架構。挑選 immich 大概算是公認做得不錯的相簿管理服務,其介面跟 Google Photos 很像,且支援人臉辨識也有依照地圖(OpenStreetMap)顯示照片拍攝位置


而 immich 官方文件有非常方便架設 Docker 的筆記:
在 Synology NAS 上,若有支援 Docker 的,可以在 Container Manager 中新增專案,這時要上傳 docker-composer.yml 檔案,則是在剛剛的教學網站有顯示:
在此先決定了 NAS 上的檔案位置,把 Docker 運行所需的環境擺在 /docker/immich-app 目錄(這是 File Station 看到的路徑),他實際的路徑位置會是 /volume1/docker/immich-app 。


這時 docker-compose.yml 可透過 Container Manager 上傳好,而 example.env 則是透過 File Station 拖拉進去改名成 .env

這時直接在 Container Manager 運行時,應當會踩到問題,因為 library 或 postgres 目錄不存在,這時也繼續靠 File Station 建立,後續 Container Manager 運行就會正常了,正常到最後可以用 NAS_IP:2283 瀏覽起來,可以看到註冊畫面等。

這邊有幾個議題記錄一下:
  1. 使用 NAS 反向代理伺服器,提供 https 連線 immich 服務
  2. 在 immich 使用外部圖庫 (External Library) ,將原本的 Synology Photos 匯入
  3. 在 immich External Library 如何使用 Symbolic link 來管理
首先,NAS 反向代理伺服器的設定還滿簡單的,找一個 port 來服務 https 連線,例如 2284 ,直接把它導向到 2283 ,這樣就搞定收工,未來就有 https://NAS_IP:2284 可用了!


接著,使用外部圖庫部分,這邊跟 Docker 設定有關,必須把額外的資料像掛載進來,例如

    volumes:
      # Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
      - ${UPLOAD_LOCATION}:/usr/src/app/upload
      - /etc/localtime:/etc/localtime:ro
      - /var/services/homes/UserID/Photos:/synology-photo:ro

透過把 Synology 在使用者家目錄的位置掛進來,這樣對 immich docker 環境中,就多了 /synology-photo 路徑可以查看資料,這時在 immich 網頁設定上,直接設定外部位置在 /synology-photo ,就可以讓 immich 掃描到照片資料來分析了

然而,對於資料管理上會想要慢慢實驗,例如少量的把目錄資料加入到 immich 外部圖庫,且不需要一直改 YAML volumes?通常對系統熟悉的,就會想試試 symbolic link 架構,還能避免 NAS 上有重複資料佔著空間,然而,symbolic link 在 Docker 環境上有使用限制,有一些討論串:
我這邊的解法,其實是設法先排除 Docker 限制,只要繞過限制後,Symbolic link 還是可以使用的。目前的設計就先在 ~/Photos/ 建立一個目錄,如 immich ,接著在裡面建立 Symbolic link 到上一層 ~/Photos 想要的資料就好,而掛載到 Docker 裡仍維持在 ~/Photos 位置,而 immich 網頁上就設定 /synology-photo/immich 即可。

總結一下 Symbolic link 使用資訊:
  • Docker volumes YAML 設定不變,概念上要把 Symbolic link 來源也包覆到
    • /var/services/homes/UserID/Photos:/synology-photo:ro
  • 建立 /var/services/homes/UserID/Photos/immich 目錄
  • 在 immich 網頁上,將外部圖庫設定在 /synology-photo/immich
  • 未來想動態將資料交給 immich 服務時,就只須在 /var/services/homes/UserID/Photos/immich 內建立向上一層的目錄,確保那些享用 symbolic link 的資料都有在 Docker volumes 內即可

UserID@NAS:~/Photos/immich$ ls -l
lrwxrwxrwx+ 1 UserID users 27 Mar  6 23:25 '2020-01-01' -> '../2020-01-01/'
lrwxrwxrwx  1 UserID users 24 Mar  6 23:28 '2021-02-02' -> '../2021-02-02/'
lrwxrwxrwx  1 UserID users 27 Mar  6 23:28 '2022-03-03' -> '../2022-03-03/'
  
收工

2025年2月26日 星期三

[macOS] 使用 Windows APP (原 Microsoft RDP app) 遠端登入 Ubuntu 24.04 Desktop 遠端桌面 及 Error code: 0x207 問題排除 @ macOS 15.3.1


把自己的 Pi 5 安裝 Ubuntu 24.04 Desktop 後,原本想要裝 VNC 來提供遠端桌面的,搞了半天才發現已經有內建的 RDP 服務,接著當然就是在 macOS 上找尋很多年前已裝過的 Microsoft Windows Remote Desktop app ,找了半天也沒找到,最後才發現 macOS App Store 已更名為 Windows App

第一次在 macOS 上 Windows APP 登入到 Ubuntu 24.04 Desktop 是正常的,但很詭異的是之後一直會收到錯誤訊息:

We couldn't connect to the remote PC. This might be due to an expired password. If this keeps happening, contact your network administrator for assistance. Error code: 0x207

我也是趁這次才知道,原來 Ubuntu 24.04 Desktop 內建的 RDP 服務,必須使用者先登入過桌面才行,並且鎖住螢幕也不行。事後發現有一連串的方式解套。


首先是使用者必須先登入過桌面,那就開啟自動登入就好,剛好在 Pi 5 實驗裝置不會踩到資安議題。接著是鎖住螢幕的問題,有一派是調整成不自動鎖住,此時發現還可以透過額外安裝 GNOME Extension Manager,並且在裡頭下載 Allow Locked Remote Desktop 就好,設定好重啟。

$ sudo apt install gnome-shell-extension-manager

最後,關於 macOS 上第二次起登入 Ubuntu Desktop 總是碰到的 Error code: 0x207 問題,神人解法是先把設定檔匯出,接著修改關鍵設定,把 "use redirection server name:i:0" 更新成 "use redirection server name:i:1" ,再匯入回去即可搞定。有些討論串認為是 macOS - Windows app 的 bug


搞定

ref: