aliyun-controller 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ """
2
+ Aliyun Controller
3
+ """
4
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ # 阿里云访问密钥配置示例
2
+ # 复制此文件为 config.yaml 并填入你的实际密钥
3
+
4
+ access_key_id: your_access_key_id
5
+ access_key_secret: your_access_key_secret
@@ -0,0 +1,186 @@
1
+ import datetime
2
+ import re
3
+ import logging
4
+ import traceback
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+ from InquirerPy import prompt
9
+ from InquirerPy.base.control import Choice
10
+ from aliyun_controller.modules.billing import get_outbound_traffic_module, summarize_billing_module
11
+ from aliyun_controller.modules.dns import dns_management_module
12
+
13
+ # 配置日志
14
+ logging.basicConfig(
15
+ level=logging.INFO,
16
+ format='%(asctime)s - %(levelname)s - %(message)s',
17
+ handlers=[
18
+ logging.FileHandler("app.log", encoding='utf-8'),
19
+ logging.StreamHandler()
20
+ ]
21
+ )
22
+
23
+ def parse_args():
24
+ """解析命令行参数"""
25
+ parser = argparse.ArgumentParser(description="阿里云控制台工具")
26
+ parser.add_argument(
27
+ "-D", "--dir",
28
+ help="配置文件目录路径",
29
+ default=os.path.expanduser("~/.config/aliyun-controller")
30
+ )
31
+ return parser.parse_args()
32
+
33
+ def _prompt_for_billing_cycle() -> str | None:
34
+ """
35
+ 提示用户输入账单周期,并验证格式。
36
+ 支持 YYYY-MM 和 YYYY-M 格式。
37
+ 如果用户取消输入,则返回 None。
38
+ """
39
+ date_prompt = [
40
+ {
41
+ "type": "input",
42
+ "message": "请输入要查询的月份 (格式 YYYY-MM 或 YYYY-M):",
43
+ "name": "cycle",
44
+ "validate": lambda val: re.match(r"^\d{4}-(0?[1-9]|1[0-2])$", val) is not None,
45
+ "invalid_message": "格式错误,请输入 YYYY-MM 或 YYYY-M 格式的月份。"
46
+ }
47
+ ]
48
+ try:
49
+ answer = prompt(date_prompt)
50
+ if not answer: # 用户按 Ctrl+C
51
+ return None
52
+
53
+ cycle = answer.get("cycle")
54
+ year, month = cycle.split('-')
55
+ if len(month) == 1:
56
+ month = '0' + month
57
+ return f"{year}-{month}"
58
+ except KeyboardInterrupt:
59
+ return None
60
+ except Exception as e:
61
+ logging.error(f"输入月份时发生错误: {e}")
62
+ logging.debug(traceback.format_exc())
63
+ return None
64
+
65
+ def query_and_repeat(query_function):
66
+ """
67
+ 包装查询函数:先查当月,然后提供子菜单让用户选择继续查询或返回。
68
+ :param query_function: 接受 billing_cycle 参数的查询函数。
69
+ """
70
+ # 1. 默认查询当月
71
+ current_cycle = datetime.datetime.now().strftime("%Y-%m")
72
+ print(f"\n--- 正在查询默认月份 {current_cycle} 的账单 ---")
73
+ try:
74
+ query_function(current_cycle)
75
+ except Exception as e:
76
+ logging.error(f"查询账单时发生错误: {e}")
77
+ logging.debug(traceback.format_exc())
78
+ print(f"\n查询账单时发生错误,请查看日志了解详情。")
79
+
80
+ # 2. 进入子菜单循环
81
+ while True:
82
+ sub_menu_prompt = [
83
+ {
84
+ "type": "list",
85
+ "message": "请选择接下来的操作:",
86
+ "choices": [
87
+ Choice("set_date", name="查询其他月份"),
88
+ Choice("return", name="返回主菜单")
89
+ ],
90
+ "name": "sub_action"
91
+ }
92
+ ]
93
+
94
+ try:
95
+ result = prompt(sub_menu_prompt)
96
+ if not result: # 用户按 Ctrl+C
97
+ print("\n已返回主菜单。")
98
+ break
99
+
100
+ action = result.get("sub_action")
101
+ if action == "set_date":
102
+ new_cycle = _prompt_for_billing_cycle()
103
+ if new_cycle:
104
+ print(f"\n--- 正在查询 {new_cycle} 的账单 ---")
105
+ try:
106
+ query_function(new_cycle)
107
+ except Exception as e:
108
+ logging.error(f"查询账单时发生错误: {e}")
109
+ logging.debug(traceback.format_exc())
110
+ print(f"\n查询账单时发生错误,请查看日志了解详情。")
111
+ else:
112
+ print("\n输入已取消。")
113
+ continue # 重新显示子菜单
114
+ elif action == "return":
115
+ print("\n已返回主菜单。")
116
+ break
117
+ except Exception as e:
118
+ logging.error(f"执行操作时发生错误: {e}")
119
+ logging.debug(traceback.format_exc())
120
+ print(f"\n执行操作时发生错误,请查看日志了解详情。")
121
+ continue
122
+
123
+ def main():
124
+ """
125
+ 主函数,提供交互式菜单
126
+ """
127
+ args = parse_args()
128
+
129
+ # 设置配置目录环境变量,供模块使用
130
+ os.environ['ALIYUN_CONTROLLER_CONFIG_DIR'] = args.dir
131
+
132
+ print("阿里云控制台工具")
133
+ print("=" * 30)
134
+
135
+ while True:
136
+ questions = [
137
+ {
138
+ "type": "list",
139
+ "message": "请选择要执行的功能:",
140
+ "choices": [
141
+ Choice("get_traffic", name="1. 查询总流出流量"),
142
+ Choice("summarize_bill", name="2. 归纳账单"),
143
+ Choice("manage_dns", name="3. DNS解析管理"),
144
+ Choice(value=None, name="[退出]")
145
+ ],
146
+ "name": "action",
147
+ }
148
+ ]
149
+
150
+ try:
151
+ result = prompt(questions)
152
+ if not result:
153
+ print("\n已退出。")
154
+ break
155
+
156
+ action = result.get("action")
157
+
158
+ if action == "get_traffic":
159
+ query_and_repeat(get_outbound_traffic_module)
160
+ elif action == "summarize_bill":
161
+ query_and_repeat(summarize_billing_module)
162
+ elif action == "manage_dns":
163
+ try:
164
+ dns_management_module()
165
+ except Exception as e:
166
+ logging.error(f"DNS管理模块发生错误: {e}")
167
+ logging.debug(traceback.format_exc())
168
+ print(f"\nDNS管理模块发生错误,请查看日志了解详情。")
169
+ elif action is None:
170
+ print("已退出。")
171
+ break
172
+ except Exception as e:
173
+ logging.error(f"主菜单执行时发生错误: {e}")
174
+ logging.debug(traceback.format_exc())
175
+ print(f"\n执行操作时发生错误,请查看日志了解详情。")
176
+ continue
177
+
178
+ if __name__ == "__main__":
179
+ try:
180
+ main()
181
+ except KeyboardInterrupt:
182
+ print("\n\n检测到中断,程序已退出。")
183
+ except Exception as e:
184
+ logging.error(f"程序运行时发生未处理的错误: {e}")
185
+ logging.debug(traceback.format_exc())
186
+ print(f"\n程序运行时发生未处理的错误,请查看日志了解详情。")
@@ -0,0 +1,3 @@
1
+ """
2
+ Aliyun Controller Modules
3
+ """
@@ -0,0 +1,189 @@
1
+ import os
2
+ import yaml
3
+ from pathlib import Path
4
+ from alibabacloud_bssopenapi20171214.client import Client as BssOpenApi20171214Client
5
+ from alibabacloud_bssopenapi20171214.models import DescribeInstanceBillRequest
6
+ from alibabacloud_tea_openapi import models as open_api_models
7
+
8
+ def load_config():
9
+ """加载配置文件"""
10
+ config_dir = os.environ.get('ALIYUN_CONTROLLER_CONFIG_DIR',
11
+ os.path.expanduser('~/.config/aliyun-controller'))
12
+ config_path = Path(config_dir) / 'config.yaml'
13
+ example_path = Path(config_dir) / 'config.yaml.example'
14
+ package_example_path = Path(__file__).parent.parent / 'config.yaml.example'
15
+
16
+ # 检查配置目录是否存在,如果不存在则创建
17
+ config_path.parent.mkdir(parents=True, exist_ok=True)
18
+
19
+ # 如果示例文件不存在,则从包中复制
20
+ if not example_path.exists() and package_example_path.exists():
21
+ import shutil
22
+ shutil.copy(package_example_path, example_path)
23
+
24
+ # 如果配置文件不存在,则报错退出
25
+ if not config_path.exists():
26
+ print("配置文件不存在,请参考 config.yaml.example 创建 config.yaml 文件")
27
+ raise FileNotFoundError(f"配置文件不存在: {config_path}")
28
+
29
+ with open(config_path, 'r', encoding='utf-8') as f:
30
+ return yaml.safe_load(f)
31
+
32
+ class AliCloudBssQuerier:
33
+ def __init__(self):
34
+ """
35
+ 初始化客户端
36
+ """
37
+ config = load_config()
38
+ self.client = BssOpenApi20171214Client(
39
+ open_api_models.Config(
40
+ access_key_id=config['access_key_id'],
41
+ access_key_secret=config['access_key_secret'],
42
+ region_id="cn-hangzhou",
43
+ )
44
+ )
45
+
46
+ def fetch_bill_details(self, billing_cycle: str, subscription_type: str) -> list:
47
+ """
48
+ 根据指定的账单类型,分页获取所有账单明细。
49
+ """
50
+ all_items = []
51
+ next_token = None
52
+ try:
53
+ while True:
54
+ request = DescribeInstanceBillRequest(
55
+ billing_cycle=billing_cycle,
56
+ subscription_type=subscription_type,
57
+ is_billing_item=True,
58
+ max_results=300
59
+ )
60
+ if next_token:
61
+ request.next_token = next_token
62
+
63
+ response = self.client.describe_instance_bill(request)
64
+
65
+ response_dict = response.body.to_map()
66
+ data = response_dict.get('Data', {})
67
+ if not data:
68
+ break
69
+
70
+ items_list = data.get('Items', [])
71
+ all_items.extend(items_list)
72
+
73
+ next_token = data.get('NextToken')
74
+ if not next_token:
75
+ break
76
+
77
+ return all_items
78
+
79
+ except Exception as e:
80
+ print(f"\n查询 [{subscription_type}] 类型账单时出错: {e}")
81
+ return []
82
+
83
+ def fetch_all_bill_details(self, billing_cycle: str) -> list:
84
+ """
85
+ 获取所有类型的账单明细(PayAsYouGo + Subscription)
86
+ """
87
+ all_items = []
88
+ all_items.extend(self.fetch_bill_details(billing_cycle, 'PayAsYouGo'))
89
+ all_items.extend(self.fetch_bill_details(billing_cycle, 'Subscription'))
90
+ return all_items
91
+
92
+ def convert_usage_to_bytes(self, usage: float, unit: str) -> float:
93
+ """
94
+ 将用量转换为字节
95
+ """
96
+ unit = unit.upper()
97
+ if unit == 'GB':
98
+ return usage * 1024 * 1024 * 1024
99
+ elif unit == 'MB':
100
+ return usage * 1024 * 1024
101
+ elif unit == 'KB':
102
+ return usage * 1024
103
+ else:
104
+ return usage
105
+
106
+ def get_outbound_traffic_module(billing_cycle: str):
107
+ """
108
+ 流量查询模块
109
+ """
110
+ querier = AliCloudBssQuerier()
111
+ total_usage_bytes = 0.0
112
+
113
+ TRAFFIC_ITEMS_CODES = [
114
+ "ECS_Out_Bytes",
115
+ "Eip_Out_Bytes",
116
+ "Cdn_domestic_flow",
117
+ "Cdn_overseas_flow",
118
+ "OSS_Out_Traffic",
119
+ ]
120
+
121
+ print(f"\n正在查询账单周期 {billing_cycle} 的账单明细...")
122
+
123
+ all_items = querier.fetch_all_bill_details(billing_cycle)
124
+
125
+ if not all_items:
126
+ print("未发现任何账单明细。")
127
+ return
128
+
129
+ print("账单明细获取成功,开始计算总流量...")
130
+ for item in all_items:
131
+ if item.get('BillingItemCode') in TRAFFIC_ITEMS_CODES:
132
+ usage_str = item.get('Usage')
133
+ unit = (item.get('UsageUnit') or '').upper()
134
+ if usage_str:
135
+ try:
136
+ usage = float(usage_str)
137
+ if usage > 0:
138
+ usage_bytes = querier.convert_usage_to_bytes(usage, unit)
139
+ total_usage_bytes += usage_bytes
140
+ except ValueError:
141
+ continue
142
+
143
+ total_traffic_gb = total_usage_bytes / (1024 * 1024 * 1024)
144
+ print("\n" + "="*45)
145
+ print(f"账单周期 {billing_cycle} 的总公网流出流量: {total_traffic_gb:.4f} GB")
146
+ print("="*45)
147
+
148
+ def summarize_billing_module(billing_cycle: str):
149
+ """
150
+ 当月完整账单归纳模块
151
+ """
152
+ querier = AliCloudBssQuerier()
153
+ summary = {}
154
+
155
+ print(f"\n正在获取账单周期 {billing_cycle} 的所有账单明细...")
156
+ all_items = querier.fetch_all_bill_details(billing_cycle)
157
+
158
+ if not all_items:
159
+ print("未发现任何账单明细。")
160
+ return
161
+
162
+ for item in all_items:
163
+ product_code = item.get('ProductCode', 'Unknown')
164
+ product_name = item.get('ProductName', 'Unknown')
165
+ amount = float(item.get('PretaxAmount', 0.0))
166
+ if product_code not in summary:
167
+ summary[product_code] = {'product_name': product_name, 'total_amount': 0.0, 'count': 0}
168
+ summary[product_code]['product_name'] = product_name # 更新产品名称(同一产品代码可能有多个名称,取最后一个)
169
+ summary[product_code]['total_amount'] += amount
170
+ summary[product_code]['count'] += 1
171
+
172
+ # 按金额从大到小排序
173
+ sorted_summary = sorted(summary.items(), key=lambda x: x[1]['total_amount'], reverse=True)
174
+
175
+ print("\n" + "="*70)
176
+ print(f"账单周期 {billing_cycle} 消费归纳".center(70))
177
+ print("="*70)
178
+ print(f"{'产品名称':<25} {'产品代码':<15} {'账单条数':<10} {'总金额 (元)':<15}")
179
+ print("-"*70)
180
+
181
+ total_amount = 0.0
182
+ for product_code, data in sorted_summary:
183
+ total_amount += data['total_amount']
184
+ product_name = data['product_name'][:24] # 截断过长的产品名称
185
+ print(f"{product_name:<25} {product_code:<15} {data['count']:<10} {data['total_amount']:<15.2f}")
186
+
187
+ print("-"*70)
188
+ print(f"总计: {total_amount:.2f} 元".rjust(70))
189
+ print("="*70)
@@ -0,0 +1,520 @@
1
+ import os
2
+ import re
3
+ import yaml
4
+ from pathlib import Path
5
+ from InquirerPy.resolver import prompt
6
+ from InquirerPy.base.control import Choice
7
+ from alibabacloud_tea_openapi import models as open_api_models
8
+ from alibabacloud_alidns20150109.client import Client as Alidns20150109Client
9
+ from alibabacloud_alidns20150109 import models as alidns_20150109_models
10
+
11
+ def load_config():
12
+ """加载配置文件"""
13
+ config_dir = os.environ.get('ALIYUN_CONTROLLER_CONFIG_DIR',
14
+ os.path.expanduser('~/.config/aliyun-controller'))
15
+ config_path = Path(config_dir) / 'config.yaml'
16
+ example_path = Path(config_dir) / 'config.yaml.example'
17
+ package_example_path = Path(__file__).parent.parent / 'config.yaml.example'
18
+
19
+ # 检查配置目录是否存在,如果不存在则创建
20
+ config_path.parent.mkdir(parents=True, exist_ok=True)
21
+
22
+ # 如果示例文件不存在,则从包中复制
23
+ if not example_path.exists() and package_example_path.exists():
24
+ import shutil
25
+ shutil.copy(package_example_path, example_path)
26
+
27
+ # 如果配置文件不存在,则报错退出
28
+ if not config_path.exists():
29
+ print("配置文件不存在,请参考 config.yaml.example 创建 config.yaml 文件")
30
+ raise FileNotFoundError(f"配置文件不存在: {config_path}")
31
+
32
+ with open(config_path, 'r', encoding='utf-8') as f:
33
+ return yaml.safe_load(f)
34
+
35
+ class AliCloudDnsQuerier:
36
+ def __init__(self):
37
+ """
38
+ 初始化DNS客户端
39
+ """
40
+ config = load_config()
41
+ self.client = Alidns20150109Client(
42
+ open_api_models.Config(
43
+ access_key_id=config['access_key_id'],
44
+ access_key_secret=config['access_key_secret'],
45
+ endpoint="dns.aliyuncs.com",
46
+ )
47
+ )
48
+
49
+ def get_domains(self) -> list:
50
+ """
51
+ 获取所有可管理的域名列表
52
+ """
53
+ request = alidns_20150109_models.DescribeDomainsRequest()
54
+ try:
55
+ response = self.client.describe_domains(request)
56
+ return response.body.to_map().get('Domains', {}).get('Domain', [])
57
+ except Exception as e:
58
+ print(f"\n获取域名列表时出错: {e}")
59
+ return []
60
+
61
+ def get_domain_records(self, domain_name: str) -> list:
62
+ """
63
+ 获取指定域名的所有解析记录
64
+ """
65
+ all_records = []
66
+ page_number = 1
67
+ page_size = 500
68
+ try:
69
+ while True:
70
+ request = alidns_20150109_models.DescribeDomainRecordsRequest(
71
+ domain_name=domain_name,
72
+ page_number=page_number,
73
+ page_size=page_size
74
+ )
75
+ response = self.client.describe_domain_records(request)
76
+ response_dict = response.body.to_map()
77
+ records = response_dict.get('DomainRecords', {}).get('Record', [])
78
+ if not records:
79
+ break
80
+ all_records.extend(records)
81
+ total_count = response_dict.get('TotalCount')
82
+ if len(all_records) >= total_count:
83
+ break
84
+ page_number += 1
85
+ return all_records
86
+ except Exception as e:
87
+ print(f"\n获取域名 {domain_name} 的解析记录时出错: {e}")
88
+ return []
89
+
90
+ def add_domain_record(self, domain_name: str, rr: str, type: str, value: str, ttl: int = 600) -> bool:
91
+ """
92
+ 添加新的解析记录
93
+ """
94
+ # 验证输入参数
95
+ if not self._validate_dns_record(rr, type, value, ttl):
96
+ return False
97
+
98
+ request = alidns_20150109_models.AddDomainRecordRequest(
99
+ domain_name=domain_name,
100
+ rr=rr,
101
+ type=type,
102
+ value=value,
103
+ ttl=ttl
104
+ )
105
+ try:
106
+ self.client.add_domain_record(request)
107
+ print(f"\n成功添加解析记录: {rr}.{domain_name} -> {value}")
108
+ return True
109
+ except Exception as e:
110
+ print(f"\n添加解析记录时出错: {e}")
111
+ return False
112
+
113
+ def update_domain_record(self, record_id: str, rr: str, type: str, value: str, ttl: int = 600) -> bool:
114
+ """
115
+ 更新现有的解析记录
116
+ """
117
+ # 验证输入参数
118
+ if not self._validate_dns_record(rr, type, value, ttl):
119
+ return False
120
+
121
+ request = alidns_20150109_models.UpdateDomainRecordRequest(
122
+ record_id=record_id,
123
+ rr=rr,
124
+ type=type,
125
+ value=value,
126
+ ttl=ttl
127
+ )
128
+ try:
129
+ self.client.update_domain_record(request)
130
+ print(f"\n成功更新解析记录 (ID: {record_id})")
131
+ return True
132
+ except Exception as e:
133
+ print(f"\n更新解析记录时出错: {e}")
134
+ return False
135
+
136
+ def delete_domain_record(self, record_id: str) -> bool:
137
+ """
138
+ 删除解析记录
139
+ """
140
+ request = alidns_20150109_models.DeleteDomainRecordRequest(
141
+ record_id=record_id
142
+ )
143
+ try:
144
+ self.client.delete_domain_record(request)
145
+ print(f"\n成功删除解析记录 (ID: {record_id})")
146
+ return True
147
+ except Exception as e:
148
+ print(f"\n删除解析记录时出错: {e}")
149
+ return False
150
+
151
+ def _validate_dns_record(self, rr: str, type: str, value: str, ttl: int) -> bool:
152
+ """
153
+ 验证DNS记录参数的合法性
154
+ """
155
+ # 验证主机记录
156
+ if not rr or len(rr) > 253:
157
+ print("\n主机记录不能为空且长度不能超过253个字符")
158
+ return False
159
+
160
+ # 验证记录类型
161
+ valid_types = ['A', 'CNAME', 'MX', 'TXT', 'SRV', 'AAAA', 'NS', 'ANAME']
162
+ if type.upper() not in valid_types:
163
+ print(f"\n不支持的记录类型: {type}")
164
+ return False
165
+
166
+ # 验证记录值
167
+ if not value:
168
+ print("\n记录值不能为空")
169
+ return False
170
+
171
+ # 验证TTL (60-86400秒)
172
+ if not (60 <= ttl <= 86400):
173
+ print("\nTTL值必须在60-86400之间")
174
+ return False
175
+
176
+ # 针对不同类型的记录进行额外验证
177
+ if type.upper() == 'A':
178
+ # IPv4地址验证
179
+ if not re.match(r'^(\d{1,3}\.){3}\d{1,3}$', value):
180
+ print("\nA记录的值必须是有效的IPv4地址")
181
+ return False
182
+ parts = value.split('.')
183
+ if any(int(part) > 255 for part in parts):
184
+ print("\nA记录的值必须是有效的IPv4地址")
185
+ return False
186
+ elif type.upper() == 'AAAA':
187
+ # IPv6地址验证(简单验证)
188
+ if not re.match(r'^[0-9a-fA-F:]+$', value):
189
+ print("\nAAAA记录的值必须是有效的IPv6地址")
190
+ return False
191
+ elif type.upper() == 'CNAME':
192
+ # CNAME验证(简单验证)
193
+ if not re.match(r'^[a-zA-Z0-9.-]+$', value):
194
+ print("\nCNAME记录的值格式不正确")
195
+ return False
196
+ if value.endswith('.'):
197
+ print("\nCNAME记录的值不能以点结尾")
198
+ return False
199
+
200
+ return True
201
+
202
+ def sort_records(self, records: list, sort_type: int, sort_order: int) -> list:
203
+ """
204
+ 对DNS记录进行排序
205
+ :param records: DNS记录列表
206
+ :param sort_type: 排序类型 (0-创建时间, 1-二级域名, 2-首字母)
207
+ :param sort_order: 排序顺序 (0-逆序, 1-正序)
208
+ :return: 排序后的记录列表
209
+ """
210
+ if sort_type == 0:
211
+ # 按创建时间排序 (默认就是按创建时间排序)
212
+ if sort_order == 0: # 逆序
213
+ records.reverse()
214
+ elif sort_type == 1:
215
+ # 按二级域名字母排序
216
+ records.sort(
217
+ key=lambda r: r.get('RR', '').split('.')[-1] if '.' in r.get('RR', '') else r.get('RR', ''),
218
+ reverse=(sort_order == 0)
219
+ )
220
+ elif sort_type == 2:
221
+ # 按首字母排序
222
+ records.sort(
223
+ key=lambda r: r.get('RR', ''),
224
+ reverse=(sort_order == 0)
225
+ )
226
+ return records
227
+
228
+ def dns_management_module():
229
+ """
230
+ DNS解析管理模块
231
+ """
232
+ dns_querier = AliCloudDnsQuerier()
233
+
234
+ while True: # 循环用于域名选择
235
+ domains = dns_querier.get_domains()
236
+ if not domains:
237
+ print("未能获取到任何域名,请检查您的账户权限或配置。")
238
+ return
239
+
240
+ domain_choices = [
241
+ Choice(value=domain['DomainName'], name=domain['DomainName']) for domain in domains
242
+ ]
243
+ domain_choices.append(Choice(value=None, name="[返回主菜单]"))
244
+
245
+ questions = [
246
+ {
247
+ "type": "list",
248
+ "message": "请选择要管理的域名:",
249
+ "choices": domain_choices,
250
+ "name": "domain_name",
251
+ }
252
+ ]
253
+
254
+ result = prompt(questions)
255
+ if not result: # 用户在域名选择时按 Ctrl+C
256
+ print("\n操作已取消,返回主菜单。")
257
+ return
258
+
259
+ selected_domain = result.get("domain_name")
260
+ if not selected_domain: # 用户选择 [返回主菜单]
261
+ return
262
+
263
+ # 排序设置:类型(0-创建时间, 1-二级域名, 2-首字母) 和 顺序(0-逆序, 1-正序)
264
+ sort_type = 0 # 默认按创建时间排序
265
+ sort_order = 0 # 默认逆序
266
+
267
+ while True: # 循环用于对选定域名进行操作
268
+ records = dns_querier.get_domain_records(selected_domain)
269
+ # 根据排序设置对记录进行排序
270
+ records = dns_querier.sort_records(records, sort_type, sort_order)
271
+
272
+ # 构建记录选项列表
273
+ record_choices = []
274
+ if records:
275
+ for i, record in enumerate(records):
276
+ record_name = f"{record.get('RR'):<20} {record.get('Type'):<10} {record.get('Value'):<30} {record.get('TTL')}"
277
+ record_choices.append(Choice(value=i, name=record_name))
278
+
279
+ # 添加分隔线(不可选择)
280
+ record_choices.append(Choice(value="separator", name="-" * 80, enabled=False))
281
+
282
+ # 添加操作选项
283
+ sort_type_text = ["创建时间", "二级域名", "首字母"][sort_type]
284
+ sort_order_text = "逆序" if sort_order == 0 else "正序"
285
+ record_choices.extend([
286
+ Choice(value="add", name="新增解析记录"),
287
+ Choice(value="sort", name=f"排序设置 [{sort_type_text}, {sort_order_text}]"),
288
+ Choice(value="refresh", name="刷新记录列表"),
289
+ Choice(value=None, name="[返回域名选择]")
290
+ ])
291
+
292
+ print("\n" + "="*80)
293
+ print(f"域名 {selected_domain} 的解析记录".center(80))
294
+ print("="*80)
295
+
296
+ if not records:
297
+ print("未找到任何解析记录。")
298
+ print("="*80)
299
+ # 如果没有记录,只显示添加和返回选项
300
+ action_choices = [
301
+ Choice(value="add", name="新增解析记录"),
302
+ Choice(value=None, name="[返回域名选择]")]
303
+ action_questions = [
304
+ {
305
+ "type": "list",
306
+ "message": "请选择操作:",
307
+ "choices": action_choices,
308
+ "name": "dns_action",
309
+ }
310
+ ]
311
+ else:
312
+ print(f"{'主机记录(RR)':<20} {'类型':<10} {'记录值(Value)':<30} {'TTL'}")
313
+ print("--------------------------------------------------------------------------------")
314
+ action_questions = [
315
+ {
316
+ "type": "list",
317
+ "message": "请选择要操作的记录或操作:",
318
+ "choices": record_choices,
319
+ "name": "dns_action",
320
+ }
321
+ ]
322
+
323
+ action_result = prompt(action_questions)
324
+ if not action_result: # 用户在操作选择时按 Ctrl+C
325
+ print("\n操作已取消,返回域名选择。")
326
+ break # 退出操作循环,返回域名选择
327
+
328
+ dns_action = action_result.get("dns_action")
329
+ if not dns_action: # 用户选择 [返回域名选择]
330
+ break
331
+
332
+ # 处理记录选择
333
+ if isinstance(dns_action, int) and 0 <= dns_action < len(records):
334
+ selected_record = records[dns_action]
335
+
336
+ # 为选中的记录提供编辑/删除选项
337
+ record_action_questions = [
338
+ {
339
+ "type": "list",
340
+ "message": f"对记录 {selected_record.get('RR')}.{selected_domain} ({selected_record.get('Type')}: {selected_record.get('Value')}) 执行操作:",
341
+ "choices": [
342
+ Choice("edit", "编辑记录"),
343
+ Choice("delete", "删除记录"),
344
+ Choice(value=None, name="[取消]")
345
+ ],
346
+ "name": "record_action",
347
+ }
348
+ ]
349
+
350
+ record_action_result = prompt(record_action_questions)
351
+ if not record_action_result:
352
+ print("\n操作已取消,返回记录列表。")
353
+ continue
354
+
355
+ record_action = record_action_result.get("record_action")
356
+ if not record_action:
357
+ print("\n操作已取消,返回记录列表。")
358
+ continue
359
+
360
+ if record_action == "edit":
361
+ print(f"\n您正在编辑以下记录:")
362
+ print(f" 主机记录 (RR): {selected_record.get('RR')}")
363
+ print(f" 记录类型 (Type): {selected_record.get('Type')}")
364
+ print(f" 记录值 (Value): {selected_record.get('Value')}")
365
+ print(f" TTL: {selected_record.get('TTL')}")
366
+
367
+ update_fields_questions = [
368
+ {"type": "input", "message": f"新的主机记录 (当前: {selected_record.get('RR')}, 留空则不修改):", "name": "rr", "default": selected_record.get('RR')},
369
+ {"type": "input", "message": f"新的记录类型 (当前: {selected_record.get('Type')}, 留空则不修改):", "name": "type", "default": selected_record.get('Type')},
370
+ {"type": "input", "message": f"新的记录值 (当前: {selected_record.get('Value')}, 留空则不修改):", "name": "value", "default": selected_record.get('Value')},
371
+ {"type": "input", "message": f"新的TTL (当前: {selected_record.get('TTL')}, 留空则不修改):", "name": "ttl", "default": str(selected_record.get('TTL'))},
372
+ ]
373
+ update_answers = prompt(update_fields_questions)
374
+ if not update_answers:
375
+ print("\n操作已取消,返回记录列表。")
376
+ continue
377
+
378
+ # 只有当用户输入了新值时才使用新值,否则保留原值
379
+ rr = update_answers.get('rr') or selected_record.get('RR')
380
+ type_val = (update_answers.get('type') or selected_record.get('Type')).upper()
381
+ value = update_answers.get('value') or selected_record.get('Value')
382
+ ttl = int(update_answers.get('ttl') or selected_record.get('TTL'))
383
+
384
+ dns_querier.update_domain_record(
385
+ record_id=selected_record.get('RecordId'),
386
+ rr=rr,
387
+ type=type_val,
388
+ value=value,
389
+ ttl=ttl
390
+ )
391
+
392
+ elif record_action == "delete":
393
+ full_record_name = f"{selected_record.get('RR')}.{selected_domain}"
394
+ confirmation_question = [
395
+ {
396
+ "type": "confirm",
397
+ "message": f"确定要删除解析记录 {full_record_name} (类型: {selected_record.get('Type')}, 值: {selected_record.get('Value')}) 吗?",
398
+ "default": False,
399
+ "name": "confirm_delete",
400
+ }
401
+ ]
402
+ confirmation_result = prompt(confirmation_question)
403
+ if not confirmation_result:
404
+ print("\n操作已取消,返回记录列表。")
405
+ continue
406
+
407
+ if confirmation_result.get("confirm_delete"):
408
+ dns_querier.delete_domain_record(selected_record.get('RecordId'))
409
+ else:
410
+ print("删除操作已取消。")
411
+
412
+ # 继续显示记录列表
413
+ continue
414
+
415
+ # 处理其他操作
416
+ if dns_action == "add":
417
+ add_questions = [
418
+ {"type": "input", "message": "主机记录 (例如 www):", "name": "rr"},
419
+ {"type": "input", "message": "记录类型 (例如 A, CNAME):", "name": "type"},
420
+ {"type": "input", "message": "记录值:", "name": "value"},
421
+ {"type": "input", "message": "TTL (默认 600):", "name": "ttl", "default": "600"},
422
+ ]
423
+ add_answers = prompt(add_questions)
424
+ if not add_answers:
425
+ print("\n操作已取消,返回记录列表。")
426
+ continue
427
+
428
+ if not all(add_answers.get(k) for k in ['rr', 'type', 'value']):
429
+ print("\n缺少必要信息,操作取消。")
430
+ continue
431
+
432
+ # TTL 是可选的,如果用户没输入,则使用默认值
433
+ ttl_value = add_answers.get('ttl')
434
+ if not ttl_value or not ttl_value.isdigit():
435
+ ttl_value = 600
436
+ else:
437
+ ttl_value = int(ttl_value)
438
+
439
+ dns_querier.add_domain_record(
440
+ domain_name=selected_domain,
441
+ rr=add_answers['rr'],
442
+ type=add_answers['type'].upper(),
443
+ value=add_answers['value'],
444
+ ttl=ttl_value
445
+ )
446
+
447
+ elif dns_action == "sort":
448
+ # 进入排序设置子页面
449
+ # 保存当前设置以防用户取消操作
450
+ prev_sort_type = sort_type
451
+ prev_sort_order = sort_order
452
+
453
+ # 创建排序设置界面
454
+ sort_type_choices = [
455
+ Choice(0, "创建时间排序"),
456
+ Choice(1, "二级域名排序"),
457
+ Choice(2, "首字母排序")
458
+ ]
459
+
460
+ sort_order_choices = [
461
+ Choice(0, "逆序"),
462
+ Choice(1, "正序")
463
+ ]
464
+
465
+ # 显示当前选择
466
+ print("\n" + "="*50)
467
+ print("排序设置".center(50))
468
+ print("="*50)
469
+ print(f"当前设置: {['创建时间', '二级域名', '首字母'][sort_type]}, {'逆序' if sort_order == 0 else '正序'}")
470
+ print("="*50)
471
+
472
+ # 选择排序类型
473
+ sort_type_question = [
474
+ {
475
+ "type": "list",
476
+ "message": "请选择排序类型:",
477
+ "choices": sort_type_choices,
478
+ "default": sort_type,
479
+ "name": "sort_type"
480
+ }
481
+ ]
482
+
483
+ sort_type_result = prompt(sort_type_question)
484
+ if not sort_type_result or sort_type_result.get("sort_type") is None:
485
+ # 恢复原设置并返回
486
+ sort_type = prev_sort_type
487
+ sort_order = prev_sort_order
488
+ continue
489
+
490
+ new_sort_type = sort_type_result.get("sort_type")
491
+
492
+ # 选择排序顺序
493
+ sort_order_question = [
494
+ {
495
+ "type": "list",
496
+ "message": "请选择排序顺序:",
497
+ "choices": sort_order_choices,
498
+ "default": sort_order,
499
+ "name": "sort_order"
500
+ }
501
+ ]
502
+
503
+ sort_order_result = prompt(sort_order_question)
504
+ if not sort_order_result or sort_order_result.get("sort_order") is None:
505
+ # 恢复原设置并返回
506
+ sort_type = prev_sort_type
507
+ sort_order = prev_sort_order
508
+ continue
509
+
510
+ new_sort_order = sort_order_result.get("sort_order")
511
+
512
+ # 更新排序设置并直接返回主列表
513
+ sort_type = new_sort_type
514
+ sort_order = new_sort_order
515
+ print(f"\n排序设置已更新为: {['创建时间', '二级域名', '首字母'][sort_type]}, {'逆序' if sort_order == 0 else '正序'}")
516
+ print("正在返回DNS记录列表...")
517
+
518
+ elif dns_action == "refresh":
519
+ # 刷新操作,直接继续循环
520
+ continue
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: aliyun-controller
3
+ Version: 0.1.0
4
+ Summary: A command-line tool for managing Alibaba Cloud services including billing queries and DNS management
5
+ Author-email: Moha-Master <hongkongreporter@outlook.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Moha-Master/Aliyun-Controller
8
+ Project-URL: Repository, https://github.com/Moha-Master/Aliyun-Controller
9
+ Project-URL: Issues, https://github.com/Moha-Master/Aliyun-Controller/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: alibabacloud-ecs20140526
22
+ Requires-Dist: alibabacloud-bssopenapi20171214
23
+ Requires-Dist: alibabacloud-alidns20150109
24
+ Requires-Dist: InquirerPy
25
+ Requires-Dist: PyYAML
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=6.0; extra == "dev"
28
+ Requires-Dist: pytest-cov; extra == "dev"
29
+
30
+ # 阿里云控制台工具 (aliyun-controller)
31
+
32
+ 这是一个调用阿里云 Python SDK 在命令行中执行常用操作的简单工具,目前具有账单查询和 DNS 管理功能。
33
+
34
+ ## 功能特性
35
+
36
+ - **账单查询**:查询指定月份的阿里云账单总额和明细
37
+ - **流量统计**:查询指定月份的公网流出流量总量
38
+ - **DNS 管理**:管理域名解析记录,包括增删改查操作
39
+
40
+ ## 后续计划
41
+
42
+ - 加入**ECS控制**的相关内容
43
+ - 加入**OOS控制**的相关内容
44
+
45
+ ## 安装指南
46
+
47
+ ### 通过 pip 安装(推荐)
48
+
49
+ 可以通过 pip 直接安装:
50
+
51
+ ```bash
52
+ pip install aliyun-controller
53
+ ```
54
+
55
+ 安装后,可以直接使用 `aliyunctl` 命令运行程序:
56
+
57
+ ```bash
58
+ aliyunctl
59
+ ```
60
+
61
+ ### 从源码运行(开发模式)
62
+
63
+ 如果你希望进行二次开发或修改源码,可以从源码安装:
64
+
65
+ 1. 克隆此项目到本地:
66
+ ```bash
67
+ git clone <项目地址>
68
+ cd aliyun-controller
69
+ ```
70
+
71
+ 2. 创建虚拟环境(推荐):
72
+ ```bash
73
+ python -m venv venv
74
+ source venv/bin/activate # Linux/Mac
75
+ # 或在 Windows 上: venv\Scripts\activate
76
+ ```
77
+
78
+ 3. 安装依赖:
79
+ ```bash
80
+ pip install -e .
81
+ ```
82
+
83
+ 4. 创建阿里云 RAM 用户并授权:
84
+ - 登录阿里云控制台。
85
+ - 进入 RAM 访问控制。
86
+ - 在左侧导航栏选择 用户 > 创建用户。
87
+ - 设置登录名称和显示名称,勾选 为该用户自动生成AccessKey。
88
+ - 创建成功后,请务必保存好 AccessKey ID 和 AccessKey Secret,它们只显示一次。
89
+ - 为新创建的 RAM 用户授权:
90
+ - 在用户详情页,点击 添加权限。
91
+ - 选择 AliyunBSSReadOnlyAccess 和 AliyunDNSFullAccess 权限。
92
+ - 点击 确定 完成授权。
93
+
94
+ 5. 配置阿里云访问密钥:
95
+ 默认情况下,程序会在 `~/.configs/aliyun-controller` 目录下查找配置文件。
96
+ 你可以复制示例配置文件并修改:
97
+ ```bash
98
+ mkdir -p ~/.configs/aliyun-controller
99
+ cp config.yaml.example ~/.configs/aliyun-controller/config.yaml
100
+ ```
101
+ 然后编辑 `~/.configs/aliyun-controller/config.yaml` 文件,填入你创建的阿里云RAM用户的 AccessKey ID 和 AccessKey Secret:
102
+ ```yaml
103
+ access_key_id: your_access_key_id
104
+ access_key_secret: your_access_key_secret
105
+ ```
106
+
107
+ 你也可以使用 `--dir/-D` 参数指定配置文件所在的目录:
108
+ ```bash
109
+ aliyunctl -D /path/to/your/config/dir
110
+ ```
111
+
112
+ ## 使用方法
113
+
114
+ 安装后,可以直接使用 `aliyunctl` 命令运行程序:
115
+
116
+ ```bash
117
+ aliyunctl
118
+ ```
119
+
120
+ 程序将显示交互式菜单,你可以选择以下功能:
121
+
122
+ 1. **查询总流出流量**:查看指定月份的公网总流出流量
123
+ 2. **归纳账单**:查看指定月份的账单明细和总额
124
+ 3. **DNS解析管理**:管理域名解析记录
125
+
126
+ 你也可以使用 `--dir/-D` 参数指定配置文件所在的目录:
127
+
128
+ ```bash
129
+ aliyunctl -D /path/to/your/config/dir
130
+ ```
131
+
132
+ ### 账单查询
133
+
134
+ - 程序会默认查询当前月份的账单
135
+ - 你也可以输入其他月份(格式:YYYY-MM / YYYY-M)进行查询
136
+ - 支持分页查询和重新查询
137
+
138
+ ### DNS 管理
139
+
140
+ - 选择要管理的域名
141
+ - 查看所有解析记录
142
+ - 添加、编辑或删除解析记录
143
+ - 支持按不同方式排序记录(创建时间、二级域名、首字母)
144
+
145
+ ## 权限要求
146
+
147
+ 为了正常使用所有功能,你的阿里云 RAM 用户记得开放以下权限:
148
+
149
+ - `AliyunBSSReadOnlyAccess`:用于账单查询
150
+ - `AliyunDNSFullAccess`:用于 DNS 管理
151
+
152
+ ## 日志记录
153
+
154
+ 程序会在运行目录下生成 `app.log` 文件,记录操作日志和错误信息。
@@ -0,0 +1,11 @@
1
+ aliyun_controller/__init__.py,sha256=CcvZiNQXl0DTdvwra209gPf7K0t-ejcyUdYCUPHVzY4,48
2
+ aliyun_controller/config.yaml.example,sha256=Oq4EAx-yBKjgGoi79B4JHvpmP9t67tdcHeXLYvtxv4g,173
3
+ aliyun_controller/main.py,sha256=d0F7WXeV4CIopMh3gaBICwosy9ywwpxftk3zDUFH-Eg,6602
4
+ aliyun_controller/modules/__init__.py,sha256=ez9ziiPd-7wzYgWpSnce0CzZiP-VMzgIKAAHAufk_VI,34
5
+ aliyun_controller/modules/billing.py,sha256=fjy16lZr40CyzVu4YwAEACtYX76YowXwi-yKvyewoF4,6869
6
+ aliyun_controller/modules/dns.py,sha256=Sou-o-oc62UbXhjFXQxPzb41quHnSB50OhsbbF4EUXk,22167
7
+ aliyun_controller-0.1.0.dist-info/METADATA,sha256=gqch9rbcWYnVhYsLyEwfo7Y00iCihkpIV_4qmfOfagE,4811
8
+ aliyun_controller-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ aliyun_controller-0.1.0.dist-info/entry_points.txt,sha256=ciHGtsRR00gd8T1Rcpk4U-uOLp3n0zPCOjnCKCHpgBM,58
10
+ aliyun_controller-0.1.0.dist-info/top_level.txt,sha256=1J-5DktJasn2lfRPGM-FoV-kjHHBZTRFwRAWT_uLRyg,18
11
+ aliyun_controller-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aliyunctl = aliyun_controller.main:main
@@ -0,0 +1 @@
1
+ aliyun_controller