@@ -2,8 +2,8 @@ name: Sync TF Mirror
22
33on :
44 schedule :
5- - cron : ' 0 4 * * 0' # 每周日运行
6- workflow_dispatch :
5+ - cron : ' 0 4 * * 0' # 每周日凌晨 4 点自动同步
6+ workflow_dispatch : # 支持手动触发
77
88permissions :
99 contents : write
2020 with :
2121 python-version : ' 3.11'
2222
23- - name : Force Sync & Verify
23+ - name : Secure Sync & Verify
2424 shell : python
2525 run : |
2626 import json
@@ -32,16 +32,25 @@ jobs:
3232 import datetime
3333 import textwrap
3434 import hashlib
35+ import binascii
3536 import base64
3637 from pathlib import Path
3738 from concurrent.futures import ThreadPoolExecutor, as_completed
3839
39- # ================= 配置区域 =================
40+ # ==============================================================================
41+ # 1. 全局配置 (Configuration)
42+ # ==============================================================================
43+
44+ # [必须修改] 您的镜像站地址,必须以 / 结尾
45+ MIRROR_URL = 'https://redc.wgpsec.org/tf-mirror/'
46+
47+ # 需要同步的 Provider 列表
4048 PROVIDERS = [
4149 'random', 'null', 'time', 'local',
4250 'tls', 'http', 'archive', 'external'
4351 ]
4452
53+ # 需要同步的操作系统和架构
4554 TARGET_PLATFORMS = [
4655 ('linux', 'amd64'), ('linux', 'arm64'),
4756 ('windows', 'amd64'),
@@ -50,30 +59,54 @@ jobs:
5059
5160 MIRROR_ROOT = Path('public/tf-mirror')
5261 REGISTRY_API = 'https://registry.terraform.io/v1/providers'
53- MIRROR_URL = 'https://redc.wgpsec.org/tf-mirror/'
54- MAX_WORKERS = 4
55- # ===========================================
62+ MAX_WORKERS = 4 # 并发下载数
5663
57- # 1. 暴力清理旧目录
58- if MIRROR_ROOT.exists():
59- print("🧹 Cleaning up old mirror directory...")
60- shutil.rmtree(MIRROR_ROOT)
61- MIRROR_ROOT.mkdir(parents=True, exist_ok=True)
64+ # ==============================================================================
65+ # 2. 核心逻辑 (Core Logic)
66+ # ==============================================================================
6267
6368 class MirrorSync:
6469 def __init__(self, base_dir: Path):
6570 self.base_dir = base_dir
6671
67- def fetch_text(self, url):
72+ def fetch_json(self, url):
73+ """从 Terraform Registry API 获取元数据"""
6874 req = urllib.request.Request(url, headers={'User-Agent': 'TF-Mirror-Bot'})
6975 with urllib.request.urlopen(req, timeout=15) as r:
70- return r.read().decode('utf-8' )
76+ return json.loads( r.read().decode() )
7177
72- def fetch_json(self, url):
73- content = self.fetch_text(url)
74- return json.loads(content)
78+ def verify_and_convert_hash(self, file_path: Path, expected_hex: str):
79+ """
80+ [核心安全逻辑]
81+ 1. 读取已下载文件的内容,计算本地 SHA256 (Hex)。
82+ 2. 与官方 API 返回的 expected_hex 进行比对。
83+ 3. 如果一致,将 Hex 转换为 Terraform 需要的 h1:Base64 格式。
84+ """
85+ print(f" 🛡️ Verifying checksum for {file_path.name}...")
86+ sha256 = hashlib.sha256()
87+
88+ # 分块读取文件,计算 Hash (避免大文件撑爆内存)
89+ with open(file_path, 'rb') as f:
90+ for block in iter(lambda: f.read(4096), b""):
91+ sha256.update(block)
92+
93+ local_hex = sha256.hexdigest()
94+
95+ # === 步骤 A: 安全比对 ===
96+ if local_hex != expected_hex:
97+ print(f" ❌ CRITICAL: Hash Mismatch!")
98+ print(f" Expected: {expected_hex}")
99+ print(f" Actual: {local_hex}")
100+ return None # 校验失败
101+
102+ # === 步骤 B: 格式转换 ===
103+ # Terraform Network Mirror 协议要求: "h1:" + Base64编码的二进制Hash
104+ binary_hash = sha256.digest() # 获取二进制摘要
105+ b64_hash = base64.b64encode(binary_hash).decode('utf-8')
106+ return f"h1:{b64_hash}"
75107
76108 def download_file(self, url, dest_path: Path):
109+ """下载文件,如果已存在则跳过"""
77110 if dest_path.exists() and dest_path.stat().st_size > 0:
78111 return 'SKIP'
79112 try:
@@ -83,67 +116,47 @@ jobs:
83116 shutil.copyfileobj(r, f)
84117 return 'OK'
85118 except Exception as e:
86- if dest_path.exists(): dest_path.unlink()
119+ if dest_path.exists(): dest_path.unlink() # 下载失败清理垃圾
87120 raise e
88121
89- def verify_integrity(self, file_path: Path, filename: str, official_sums: str):
90- """
91- 计算本地文件的 SHA256,并检查是否在官方 SHA256SUMS 文件中
92- """
93- print(f" 🛡️ Verifying integrity for {filename}...")
94- sha256_hash = hashlib.sha256()
95- with open(file_path, "rb") as f:
96- for byte_block in iter(lambda: f.read(4096), b""):
97- sha256_hash.update(byte_block)
98-
99- # 1. 获取 Hex 格式 Hash (用于比对)
100- local_hex = sha256_hash.hexdigest()
101-
102- # 2. 构造官方校验行特征 (格式: hash filename)
103- expected_entry = f"{local_hex} {filename}"
104-
105- if expected_entry not in official_sums:
106- print(f" ❌ Hash Mismatch! Local: {local_hex}")
107- return None # 校验失败
108-
109- # 3. 生成 Terraform 需要的 h1:Base64 格式
110- digest = sha256_hash.digest()
111- b64_hash = base64.b64encode(digest).decode('utf-8')
112- return f"h1:{b64_hash}"
113-
114- def process_single_arch(self, namespace, type_name, version, os_name, arch, provider_dir, official_sums):
122+ def process_single_arch(self, namespace, type_name, version, os_name, arch, provider_dir):
123+ """处理单个架构:下载 -> 校验 -> 返回元数据"""
115124 try:
125+ # 1. 调用 API 获取下载链接和官方 Hash
116126 dl_api = f'{REGISTRY_API}/{namespace}/{type_name}/{version}/download/{os_name}/{arch}'
117127 dl_info = self.fetch_json(dl_api)
128+
118129 filename = dl_info['filename']
119130 file_url = dl_info['download_url']
131+ official_hex = dl_info['shasum'] # 官方宣称的 Hash (Hex格式)
120132
121133 dest = provider_dir / filename
122134
123- # 1. 下载
135+ # 2. 下载文件
124136 self.download_file(file_url, dest)
125137
126- # 2. 校验 (对比官方指纹)
127- tf_hash = self.verify_integrity (dest, filename, official_sums )
138+ # 3. [关键] 本地计算 Hash 并与官方比对
139+ tf_hash = self.verify_and_convert_hash (dest, official_hex )
128140
129141 if not tf_hash:
130- # 校验失败,删除文件,抛出异常
142+ # 校验失败,立即删除文件,防止污染镜像
131143 if dest.exists(): dest.unlink()
132- return {"status": "failed", "error": " Checksum verification failed"}
144+ raise ValueError(" Checksum verification failed (Local vs Official)")
133145
134146 return {
135147 "status": "success",
136148 "os": os_name,
137149 "arch": arch,
138150 "filename": filename,
139- "hash": tf_hash
151+ "hash": tf_hash # 验证通过后的 h1 Hash
140152 }
141153 except urllib.error.HTTPError:
142- return {"status": "skipped"}
154+ return {"status": "skipped"} # 架构不存在
143155 except Exception as e:
144156 return {"status": "failed", "error": str(e)}
145157
146158 def process_provider(self, full_name):
159+ """处理单个 Provider 全流程"""
147160 if '/' in full_name:
148161 namespace, type_name = full_name.split('/')
149162 else:
@@ -153,57 +166,46 @@ jobs:
153166 print(f'🔍 Analyzing {namespace}/{type_name}...')
154167
155168 try:
156- # 1. 获取版本信息
169+ # 1. 获取版本
157170 meta_url = f'{REGISTRY_API}/{namespace}/{type_name}'
158171 meta = self.fetch_json(meta_url)
159172 version = meta['version']
160173
161- # 2. 获取该版本的详细元数据 (为了拿到 shasums_url)
162- version_meta_url = f'{REGISTRY_API}/{namespace}/{type_name}/{version}'
163- version_meta = self.fetch_json(version_meta_url)
164- shasums_url = version_meta.get('shasums_url')
165- shasums_sig_url = version_meta.get('shasums_signature_url')
166-
167- if not shasums_url:
168- print(f" ⚠️ No SHA256SUMS url found for {type_name}, skipping verification.")
169- return {'status': 'failed', 'name': type_name}
170-
171- # 3. 下载官方 SHA256SUMS 内容 (用于内存校验)
172- print(f" ⬇️ Fetching official SHA256SUMS for v{version}...")
173- official_sums_content = self.fetch_text(shasums_url)
174-
174+ # 目录结构: public/tf-mirror/registry.terraform.io/NAMESPACE/TYPE/
175175 provider_dir = self.base_dir / 'registry.terraform.io' / namespace / type_name
176176 provider_dir.mkdir(parents=True, exist_ok=True)
177177
178178 archives_data = {}
179179
180- # 4. 并发下载并校验
180+ # 2. 并发处理所有架构
181181 with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
182182 futures = []
183183 for os_name, arch in TARGET_PLATFORMS:
184184 futures.append(executor.submit(
185185 self.process_single_arch,
186- namespace, type_name, version, os_name, arch, provider_dir, official_sums_content
186+ namespace, type_name, version, os_name, arch, provider_dir
187187 ))
188188
189189 for future in as_completed(futures):
190190 res = future.result()
191191 if res["status"] == "success":
192192 key = f"{res['os']}_{res['arch']}"
193- print(f" ✅ Verified: {res['filename']}")
193+ print(f" ✅ Verified & Ready : {res['filename']}")
194194 archives_data[key] = {
195195 "url": res['filename'],
196196 "hashes": [res['hash']]
197197 }
198198 elif res["status"] == "failed":
199199 print(f" ❌ Failed: {res.get('error')}")
200200
201- # 5. 生成 JSON 文件
201+ # 3. 生成索引文件
202202 if archives_data:
203+ # index.json
203204 index_data = {"versions": {version: {}}}
204205 with open(provider_dir / 'index.json', 'w') as f:
205206 json.dump(index_data, f, indent=2)
206207
208+ # VERSION.json (如 3.8.1.json)
207209 version_file_data = {"archives": archives_data}
208210 with open(provider_dir / f'{version}.json', 'w') as f:
209211 json.dump(version_file_data, f, indent=2)
@@ -213,10 +215,19 @@ jobs:
213215 return {'name': type_name, 'namespace': namespace, 'version': version, 'status': 'empty'}
214216
215217 except Exception as e:
216- print(f'🚫 Error: {namespace}/{type_name} - {e}')
218+ print(f'🚫 Error processing {namespace}/{type_name}: {e}')
217219 return {'name': type_name, 'namespace': namespace, 'version': 'Error', 'status': 'failed'}
218220
219- # --- Main ---
221+ # ==============================================================================
222+ # 3. 主程序执行 (Main Execution)
223+ # ==============================================================================
224+
225+ # 清理旧目录
226+ if MIRROR_ROOT.exists():
227+ print("🧹 Cleaning up old mirror directory...")
228+ shutil.rmtree(MIRROR_ROOT)
229+ MIRROR_ROOT.mkdir(parents=True, exist_ok=True)
230+
220231 syncer = MirrorSync(MIRROR_ROOT)
221232 results = []
222233
@@ -225,21 +236,33 @@ jobs:
225236 for f in as_completed(futures):
226237 res = f.result()
227238 results.append(res)
228- print(f"🎉 Completed {res['namespace']}/{res['name']}")
239+
240+ ns = res.get('namespace', 'unknown')
241+ nm = res.get('name', 'unknown')
242+ if res.get('status') == 'success':
243+ print(f"🎉 Completed {ns}/{nm}")
244+ else:
245+ print(f"⚠️ Skipped {ns}/{nm}")
229246
230- # --- Generate HTML ---
247+ # ==============================================================================
248+ # 4. 生成 HTML 索引页面
249+ # ==============================================================================
231250 print('📝 Generating index.html...')
232251 timestamp = datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
233252
253+ sorted_results = sorted(results, key=lambda x: (x.get('namespace', ''), x.get('name', '')))
254+
255+ # 生成 Terraform include 配置块
234256 config_lines = []
235- for r in sorted(results, key=lambda x: x['name']) :
236- if r[ 'status'] == 'success':
257+ for r in sorted_results :
258+ if r.get( 'status') == 'success':
237259 config_lines.append(f'"registry.terraform.io/{r["namespace"]}/{r["name"]}"')
238260 config_str = ",\n ".join(config_lines)
239261
262+ # 生成卡片 HTML
240263 cards_html = ""
241- for r in sorted(results, key=lambda x: x['name']) :
242- if r[ 'status'] == 'success':
264+ for r in sorted_results :
265+ if r.get( 'status') == 'success':
243266 cards_html += f"""
244267 <div class="card">
245268 <div class="name">{r['namespace']}/{r['name']}</div>
@@ -271,8 +294,9 @@ jobs:
271294 <body>
272295 <h1>Terraform Provider Mirror</h1>
273296 <div class="status">
274- <p><strong>Protocol:</strong> Network Mirror (Integrity Verified )</p>
297+ <p><strong>Protocol:</strong> Network Mirror (Strict Integrity Check )</p>
275298 <p><strong>Last Updated:</strong> {{TIMESTAMP}}</p>
299+ <p><strong>Mirror URL:</strong> <code>{{MIRROR_URL}}</code></p>
276300 </div>
277301
278302 <h2>Usage Configuration</h2>
@@ -296,14 +320,18 @@ jobs:
296320 <div class="grid">
297321 {{CARDS_HTML}}
298322 </div>
323+
324+ <footer>
325+ wgpsec terraform mirror | <a href="https://github.com/wgpsec/redc-template">Source Repository</a>
326+ </footer>
299327 </body>
300328 </html>
301329 """)
302330
303331 final_html = html_template.replace("{{TIMESTAMP}}", timestamp) \
304332 .replace("{{MIRROR_URL}}", MIRROR_URL) \
305333 .replace("{{CONFIG_STR}}", config_str) \
306- .replace("{{COUNT}}", str(len(results ))) \
334+ .replace("{{COUNT}}", str(len(config_lines ))) \
307335 .replace("{{CARDS_HTML}}", cards_html)
308336
309337 with open(MIRROR_ROOT / 'index.html', 'w', encoding='utf-8') as f:
@@ -317,4 +345,4 @@ jobs:
317345 publish_dir : ./public
318346 publish_branch : gh-pages
319347 keep_files : true
320- commit_message : " Mirror: Sync with Official Integrity Verification"
348+ commit_message : " Mirror: Sync providers with Strict Local Hash Verification"
0 commit comments