#!/usr/bin/python3
# coding=utf-8
##################################################################
#              ____     _     _ __  __                 
#             / __/__  (_)___(_) /_/ /  ___  ___  ___ _
#            _\ \/ _ \/ / __/ / __/ /__/ _ \/ _ \/ _ `/
#           /___/ .__/_/_/ /_/\__/____/\___/_//_/\_, / 
#              /_/                              /___/  
# 
# Copyright (c) 2025 Chongqing Spiritlong Technology Co., Ltd.
# All rights reserved.
# @author	arthuryang
# @brief	SSH工具
##################################################################


# key为字典{key_type, public, private, comment}#	
#	key_type	密钥类型
#	private		私钥加上大端字节数之后的base64编码字符串
#	public		算法名称+大端字节数+公钥这三者的base64编码字符串
#	comment		说明

# session为字典{hostname, address, port, user, key_file, key}
#	hostname	主机名，可以是一个域名
#	address		主机地址，可以是IP地址或者域名
#	port		端口
#	user		用户名
#	key		key字典格式的密钥

# session名称格式：当HOSTNAME是一个域名（包含了.）时，则session名称为此域名；用户名为root时为HOSTNAME，否则为用户名^HOSTNAME
# Windows下，session存放在注册表中；Linux下，session存放在~/.ssh/config
# session文件是一个xlsx文件，名为HOST的sheet中，包含USER、HOSTNAME、ADDRESS、PORT、PUBLIC和PRIVATE至少6个字段

# HOST文件是一个xlsx文件，有HOSTS和ROOT两个sheet。HOST里面至少包含HOSTNAME、ADDRESS、PORT、PASSWORD四个列，包含了需要管理的主机；ROOT里面至少包含HOSTNAME、PUBLIC（包含协议类型）、COMMENT三个列（COMMENT指示了ROOT密钥所有者)
# 	HOST文件仅供管理员使用


import	os
import	re
import	base64
import	secrets
import	string

# 仅在windows下可用
if os.name=='nt':
	import	winreg

import	hmac
import	hashlib
import	paramiko
import	SpiritLong_utility
import	SpiritLong_excel
from	nacl.signing	import	SigningKey	# pip install pynacl
from	nacl.encoding	import	Base64Encoder	as nacl_Base64Encoder
from	nacl.encoding	import	RawEncoder	as nacl_RawEncoder

# PuTTY注册表sessions路径
PUTTY_REGISTRY_PATH	= r"SOFTWARE\SimonTatham\PuTTY\Sessions"

# SSH目录位置，其中OPENSSH和PPK目录下分别是openSSH的密钥和PuTTY密钥，密钥名为session名称
if os.name=='nt':
	# Windows下应该在 用户/OneDrive/文档/SSH
	USER_SSH_BASE	= os.path.join(os.getenv('USERPROFILE'), 'OneDrive', '文档', 'SSH')
else:
	# Linux下应该在 ~/.ssh
	USER_SSH_BASE	= os.path.join(os.getenv('HOME'), '.ssh')
# 确保USER_SSH_BASE目录存在
if not os.path.exists(USER_SSH_BASE):
	os.makedirs(USER_SSH_BASE, mode=0o700)

# PPK文件所在路径（仅windows)
PPK_path	= os.path.join(USER_SSH_BASE, 'PPK')
if os.name=='nt' and not os.path.exists(PPK_path):
	os.mkdir(PPK_path)

# OPENSSH文件所在路径
OPENSSH_path	= os.path.join(USER_SSH_BASE, 'OPENSSH')
if not os.path.exists(OPENSSH_path):
	os.mkdir(OPENSSH_path)
	

# PuTTY注册表中的全部配置
PUTTY_CONFIG_DEFAULT	= {
	'HostName'			: '',
	'PortNumber'			: 22,
	'UserName'			: 'root',
	'PublicKeyFile'			: '',			# ppk文件位置
	
	# 字体
	'Font'				: 'Fixedsys',
	'FontHeight'			: 10,
	'FontIsBold'			: 0,			# 字体样式：是否加粗
	'FontQuality'			: 3,			# 字体质量 3->ClearType
	'FontCharSet'			: 134,			# 字体编码

	# Alt+Enter全屏
	'FullScreenOnAltEnter'		: 1,

	# 初始分辨率，单位为字符
	'TermWidth'			: 132,
	'TermHeight'			: 80,

	'WinTitle'			: 'arthuryang@PROGRAM',
	'Protocol'			: 'ssh',
	'BlinkCur'			: 1,
		
	# 行缓存数量	
	'ScrollbackLines'		: 999999,
	# X11转发使能才可以使用图形界面
	'X11Forward'			: 1,

	# TCP选项
	'PingInterval'			: 0,	# 发送TCPKeepalives的间隔：分钟数部分
	'PingIntervalSecs'		: 30,	# 发送TCPKeepalives的间隔：秒数部分
	'TCPNoDelay'			: 1,	# TCP_NODELAY
	'TCPKeepalives'			: 1,	# SO_KEEPALIVE

	'NoApplicationKeys'		: 1,	# 禁止应用程序小键盘，这可以在vi中正常使用小键盘
	'NoRemoteWinTitle'		: 1,	# 禁止远程修改标题

	# 以下是PuTTY默认设置
	'Present'			: 1,
	'LogFileName'			: 'putty.log',
	'LogType'			: 0,
	'LogFileClash'			: 4294967295,
	'LogFlush'			: 1,
	'LogHeader'			: 1,
	'SSHLogOmitPasswords'		: 1,
	'SSHLogOmitData'		: 0,
	'CloseOnExit'			: 1,
	'WarnOnClose'			: 1,	
	'TerminalType'			: 'xterm',
	'TerminalSpeed'			: '38400,38400',
	'TerminalModes'			: 'CS7=A,CS8=A,DISCARD=A,DSUSP=A,ECHO=A,ECHOCTL=A,ECHOE=A,ECHOK=A,ECHOKE=A,ECHONL=A,EOF=A,EOL=A,EOL2=A,ERASE=A,FLUSH=A,ICANON=A,ICRNL=A,IEXTEN=A,IGNCR=A,IGNPAR=A,IMAXBEL=A,INLCR=A,INPCK=A,INTR=A,ISIG=A,ISTRIP=A,IUCLC=A,IUTF8=A,IXANY=A,IXOFF=A,IXON=A,KILL=A,LNEXT=A,NOFLSH=A,OCRNL=A,OLCUC=A,ONLCR=A,ONLRET=A,ONOCR=A,OPOST=A,PARENB=A,PARMRK=A,PARODD=A,PENDIN=A,QUIT=A,REPRINT=A,START=A,STATUS=A,STOP=A,SUSP=A,SWTCH=A,TOSTOP=A,WERASE=A,XCASE=A',
	'AddressFamily'			: 0,
	'ProxyExcludeList'		: '',
	'ProxyDNS'			: 1,
	'ProxyLocalhost'		: 0,
	'ProxyMethod'			: 0,
	'ProxyHost'			: 'proxy',
	'ProxyPort'			: 80,
	'ProxyUsername'			: '',
	'ProxyPassword'			: '',
	'ProxyTelnetCommand'		: 'connect %host %port\n',
	'ProxyLogToTerm'		: 1,
	'Environment'			: '',
	'UserNameFromEnvironment	'	: 0,
	'LocalUserName'			: '',
	'NoPTY'				: 0,
	'Compression'			: 0,
	'TryAgent'			: 1,
	'AgentFwd'			: 0,
	'GssapiFwd'			: 0,
	'ChangeUsername'		: 0,
	'Cipher'			: 'aes,chacha20,3des,WARN,des,blowfish,arcfour',
	'KEX'				: 'ecdh,dh-gex-sha1,dh-group14-sha1,rsa,WARN,dh-group1-sha1',
	'HostKey'			: 'ed448,ed25519,ecdsa,rsa,dsa,WARN',
	'PreferKnownHostKeys'		: 1,
	'RekeyTime'			: 60,
	'GssapiRekey'			: 2,
	'RekeyBytes'			: '1G',
	'SshNoAuth'			: 0,
	'SshNoTrivialAuth'		: 0,
	'SshBanner'			: 1,
	'AuthTIS'			: 0,
	'AuthKI'			: 1,
	'AuthGSSAPI'			: 1,
	'AuthGSSAPIKEX'			: 1,
	'GSSLibs'			: 'gssapi32,sspi,custom',
	'GSSCustom'			: '',
	'SshNoShell'			: 0,
	'SshProt'			: 3,
	'LogHost'			: '',
	'SSH2DES'			: 0,
	'RemoteCommand'			: '',
	'RFCEnviron'			: 0,
	'PassiveTelnet'			: 0,
	'BackspaceIsDelete'		: 1,
	'RXVTHomeEnd'			: 0,
	'LinuxFunctionKeys'		: 0,
	'NoApplicationCursors'		: 0,
	'NoMouseReporting'		: 0,
	'NoRemoteResize'		: 0,
	'NoAltScreen'			: 0,
	'NoRemoteClearScroll'		: 0,
	'RemoteQTitleAction'		: 1,
	'NoDBackspace'			: 0,
	'NoRemoteCharset'		: 0,
	'ApplicationCursorKeys'		: 0,
	'ApplicationKeypad'		: 1,
	'NetHackKeypad'			: 0,
	'AltF4'				: 1,
	'AltSpace'			: 0,
	'AltOnly'			: 0,
	'ComposeKey'			: 0,
	'CtrlAltKeys'			: 1,
	'TelnetKey'			: 0,
	'TelnetRet'			: 1,
	'LocalEcho'			: 2,
	'LocalEdit'			: 2,
	'Answerback'			: 'PuTTY',
	'AlwaysOnTop'			: 0,
	'HideMousePtr'			: 0,
	'SunkenEdge'			: 0,
	'WindowBorder'			: 1,
	'CurType'			: 0,
	'Beep'				: 1,
	'BeepInd'			: 0,
	'BellWaveFile'			: '',
	'BellOverload'			: 1,
	'BellOverloadN'			: 5,
	'BellOverloadT'			: 2000,
	'BellOverloadS'			: 5000,
	'DECOriginMode'			: 0,
	'AutoWrapMode'			: 1,
	'LFImpliesCR'			: 0,
	'CRImpliesLF'			: 0,
	'DisableArabicShaping'		: 0,
	'DisableBidi'			: 0,
	'WinNameAlways'			: 1,
	'FontVTMode'			: 4,
	'UseSystemColours'		: 0,
	'TryPalette'			: 0,
	'ANSIColour'			: 1,
	'Xterm256Colour'		: 1,
	'TrueColour'			: 1,
	'BoldAsColour'			: 1,
	'Colour0'			: '187,187,187',
	'Colour1'			: '255,255,255',
	'Colour2'			: '0,0,0',
	'Colour3'			: '85,85,85',
	'Colour4'			: '0,0,0',
	'Colour5'			: '0,255,0',
	'Colour6'			: '0,0,0',
	'Colour7'			: '85,85,85',
	'Colour8'			: '187,0,0',
	'Colour9'			: '255,85,85',
	'Colour10'			: '0,187,0',
	'Colour11'			: '85,255,85',
	'Colour12'			: '187,187,0',
	'Colour13'			: '255,255,85',
	'Colour14'			: '0,0,187',
	'Colour15'			: '85,85,255',
	'Colour16'			: '187,0,187',
	'Colour17'			: '255,85,255',
	'Colour18'			: '0,187,187',
	'Colour19'			: '85,255,255',
	'Colour20'			: '187,187,187',
	'Colour21'			: '255,255,255',
	'RawCNP'			: 0,
	'UTF8linedraw'			: 0,
	'PasteRTF'			: 0,
	'MouseIsXterm'			: 0,
	'RectSelect'			: 0,
	'PasteControls'			: 0,
	'MouseOverride'			: 1,
	'Wordness0'			: '0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0',
	'Wordness32'			: '0,1,2,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1',
	'Wordness64'			: '1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2',
	'Wordness96'			: '1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1',
	'Wordness128'			: '1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1',
	'Wordness160'			: '1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1',
	'Wordness192'			: '2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2',
	'Wordness224'			: '2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2',
	'MouseAutocopy'			: 1,
	'MousePaste'			: 'explicit',
	'CtrlShiftIns'			: 'explicit',
	'CtrlShiftCV'			: 'none',
	'LineCodePage'			: '',
	'CJKAmbigWide'			: 0,
	'UTF8Override'			: 1,
	'Printer'			: '',
	'CapsLockCyr'			: 0,
	'ScrollBar'			: 1,
	'ScrollBarFullScreen'		: 0,
	'ScrollOnKey'			: 0,
	'ScrollOnDisp'			: 1,
	'EraseToScrollback'		: 1,
	'LockSize'			: 0,
	'BCE'				: 1,
	'BlinkText'			: 0,
	'X11Display'			: '',
	'X11AuthType'			: 1,
	'X11AuthFile'			: '',
	'LocalPortAcceptAll'		: 0,
	'RemotePortAcceptAll'		: 0,
	'PortForwardings'		: '',
	'BugIgnore1'			: 0,
	'BugPlainPW1'			: 0,
	'BugRSA1'			: 0,
	'BugIgnore2'			: 0,
	'BugHMAC2'			: 0,
	'BugDeriveKey2'			: 0,
	'BugRSAPad2'			: 0,
	'BugPKSessID2'			: 0,
	'BugRekey2'			: 0,
	'BugMaxPkt2'			: 0,
	'BugOldGex2'			: 0,
	'BugWinadj'			: 0,
	'BugChanReq'			: 0,
	'StampUtmp'			: 1,
	'LoginShell'			: 1,
	'ScrollbarOnLeft'		: 0,
	'BoldFont'			: '',
	'BoldFontIsBold'		: 0,
	'BoldFontCharSet'		: 0,
	'BoldFontHeight'		: 0,
	'WideFont'			: '',
	'WideFontIsBold'		: 0,
	'WideFontCharSet'		: 0,
	'WideFontHeight'		: 0,
	'WideBoldFont'			: '',
	'WideBoldFontIsBold'		: 0,
	'WideBoldFontCharSet'		: 0,
	'WideBoldFontHeight'		: 0,
	'ShadowBold'			: 0,
	'ShadowBoldOffset'		: 1,
	'SerialLine'			: 'COM1',
	'SerialSpeed'			: 115200,
	'SerialDataBits'		: 8,
	'SerialStopHalfbits'		: 2,
	'SerialParity'			: 0,
	'SerialFlowControl'		: 1,
	'WindowClass'			: '',
	'ConnectionSharing'		: 0,
	'ConnectionSharingUpstream'	: 1,
	'ConnectionSharingDownstream'	: 1,
	'SSHManualHostKeys'		: '',
	'SUPDUPLocation'		: 'The Internet',
	'SUPDUPCharset'			: 0,
	'SUPDUPMoreProcessing'		: 0,
	'SUPDUPScrolling'		: 0,
}

######################################## 内部工具 ########################################

## 在数据前面增加4字节长度，注意长度是大端。在SSH处理过程中使用。
def _add_header_big_Endian_bytes(data):
	if isinstance(data, str):
		# 字符串需要先转换到bytes
		data	= data.encode('ascii')
	return len(data).to_bytes(4, 'big')+data
	
## 从putty的ppk文件提取出密钥各部分信息
# 返回key字典{key_type, private, public, comment}
def _parse_ppk_file(ppk_file_path):
	
	try:
		items	= {}
		with open(ppk_file_path, 'r', encoding='utf-8') as f:
			lines	= f.readlines()
			i	= 0
			
			while i<len(lines):
				n	= 1
				try:
					title, value	= lines[i].split(':', maxsplit=1)
					value	= value.strip()
					if title.startswith('PuTTY-User-Key-File'):
						# key的算法类型
						title	= 'key_type'
					if title=='Public-Lines':
						title	= 'public'
						n	= int(value)+1
						key_value_lines	= [v.strip() for v in lines[(i+1):(i+n)]]
						value	= ''.join(key_value_lines)
					elif title=='Private-Lines':
						title	= 'private'
						n	= int(value)+1
						key_value_lines	= [v.strip() for v in lines[(i+1):(i+n)]]
						value	= ''.join(key_value_lines)
					elif title=='Comment':
						title	= 'comment'
						value	= value.strip()
					items[title]	= value
				except:
					title	= None
					value	= None
				i	+= n;			
		# 得要各元素齐备才行
		return items if all(i in items for i in ['key_type', 'private', 'public', 'comment']) else None
	except Exception as e:
		print(e)
		return None

## 从OPENSSH私钥文件提取密钥
# 返回key字典{key_type, private, public, comment}
def _parse_key_file(key_file_path):
	key_string	= ''
	try:
		with open(key_file_path, 'r', encoding='utf-8') as f:
			key_content	= 0
			while key_content<2:
				line	= f.readline()
				if "END OPENSSH PRIVATE KEY" in line:
					key_content	= 2
				if key_content==1:
					key_string	+= line.strip()
				if "BEGIN OPENSSH PRIVATE KEY" in line:
					key_content	= 1
	except Exception as e:
		print(f"Failed to read key file: {key_file_path}")
		print(e)
		return None
	
	# 得到的密钥字符串是base64编码了的
	key_bytes	= base64.b64decode(key_string)

	# 分解成多个部分
	head_bytes	= b'openssh-key-v1\0'
	offset		= 0
	parts		= []
	while offset<len(key_bytes):
		if offset==0:
			# 开头的OPENSSH密钥标识
			head	= key_bytes[:len(head_bytes)]
			if head!=head_bytes:
				print("Failed: header part wrong")
				return None 
			parts.append(head)
			offset	+= len(head)
			continue

		if len(parts)==4:
			# 接下来是4字节长度
			length	= 4
		else:
			# 其他部分都有开头的4字节
			length	= int.from_bytes(key_bytes[offset:offset+4], 'big')
			offset	+= 4
		
		# 确保不越界
		length	= min(len(key_bytes)-offset, length)

		# 获取数据
		parts.append(key_bytes[offset:offset+length])
		offset	+= length
	
	# 校验各部分
	if len(parts)!=7:
		print(f"Failed: There are {len(parts)} parts instead of 7")
		return None
	
	if parts[1]!=b'none' or parts[2]!=b'none' or parts[3]!=b'':
		print(f"Failed: cipher name/KDF name/KDF options should be none/''")
		print(parts[1], parts[2], parts[3])
		return None

	if int.from_bytes(parts[4], 'big')!=1:
		print('Failed: key n>1')
		return None

	# 公钥已经是类型+公钥了
	public_bytes		= parts[5]
	# 公钥数据
	public_key_bytes	= public_bytes[(int.from_bytes(public_bytes[:4], 'big')+4+4):]
	# 密钥类型
	key_type		= parts[5][4:4+int.from_bytes(public_bytes[:4])]

	# 私钥内包含了6个部分：校验和1、校验和2、算法、公钥、私钥+公钥、注释，每个部分前面有4字节big endian长度
	private_bytes		= parts[6]
	offset	= 0
	parts	= []
	for i in range(6):
		length	= int.from_bytes(private_bytes[offset:offset+4])
		offset	+= 4
		
		# 确保不越界
		length	= min(len(key_bytes)-offset, length)

		parts.append(private_bytes[offset:offset+length])
		offset	+= length

	for p in parts:
		print(p)

	if len(parts)<5 or public_key_bytes!=parts[3]:
		print("Failed: private key error")
		return None
	
	private_key_bytes	= parts[4][:len(parts[4])-len(public_key_bytes)]
	comment			= parts[5]

	return {
		'public'	: public_key_bytes,
		'private'	: private_key_bytes,
		'key_type'	: key_type.decode('ascii'),
		'public'	: base64.b64encode(public_bytes).decode('ascii'),
		'private'	: base64.b64encode(_add_header_big_Endian_bytes(private_key_bytes)).decode('ascii'),
		'comment'	: comment,
	}

## 连接到远程主机，返回连接对象
def _connect_server(address, port, username, password=None, OPENSSH_file=None):
	# 创建ssh对象
	ssh		= paramiko.SSHClient()

	# 加载已知HOSTS
	ssh.load_system_host_keys()
		
	# 自动接受服务器指纹
	ssh.set_missing_host_key_policy(paramiko.client.AutoAddPolicy())

	if password:
		# 密码登录
		try:
			ssh.connect(
				hostname	= address,
				username	= username,
				password	= password,
				port		= port,
				timeout		= 3,
			)
		except Exception as e:
			print('连接失败', e)
			ssh	= None
	else:
		# 密钥登录
		try:
			ssh.connect(
				hostname	= address,
				username	= username,
				pkey		= paramiko.Ed25519Key.from_private_key_file(OPENSSH_file),
				port		= port,
				timeout		= 3,
			)
		except Exception as e:
			print('connection failed', e)
			ssh	= None

	return ssh

## 通过session名称连接到远程主机，返回连接对象
#	session_name	session词典：提供address， port, user
#	OPENSSH_file	密钥文件，None则使用默认session密钥文件
def _connect_session(session_name, OPENSSH_file=None):
	# 获取sessions
	sessions	= get_sessions()
	if not sessions or session_name not in sessions:
		print(f'No sessions named {session_name}')
		return None
	s		= sessions[session_name]
	
	if OPENSSH_file is None:
		# 默认位置
		OPENSSH_file	= os.path.join(USER_SSH_BASE, OPENSSH_path, f'{session_name}.key')

	# 使用密钥连接
	return _connect_server(s['address'], s['port'], s['user'], None, OPENSSH_file)

## 从字符串获取公钥字节数组
#	key_string	公钥字符串
# 返回{key_type, public_bytes}
def _public_string_bytes(key_string):
	public_bytes	= base64.b64decode(key_string)
	public_key	= {}
	offset		= 0

	# 分解成公钥的两个部分，注意此时是原始字节数组
	for k in ['key_type', 'public']:
		length	= int.from_bytes(public_bytes[offset:(offset+4)])
		if length>len(public_bytes[offset:]):
			print('public key error')
			return None
		public_key[k]	= public_bytes[(offset+4):(offset+4+length)]
		offset		+= 4+length

	# 将各部分字节数组处理成字符串
	public_key['key_type']	= public_key['key_type'].decode('ascii')
	public_key['public']	= base64.b64decode(public_key['public'])
	
	return public_key

## 从字符串获取私钥字节数组
#	key_string	私钥字符串
# 返回key_bytes
def _private_string_bytes(key_string):
	offset	= 0
	length	= int.from_bytes(key_string[:4])
	if length>len():
		print('private key error')
		return None
	return base64.b64decode(key_string[4:(4+length)])

## 从session信息生成session_name
#	session	{hostname, address, port, user, key_file}
# 返回session名称
def _get_session_name_from_session(session):
	if '.' in session['hostname'] or session['user']=='root':
		return session['hostname']
	else:
		return f"{session['user']}^{session['hostname']}"
	
## 从session名称中解析hostname
def _get_hostname_from_sesion_name(session_name):
	if '.' in session_name:
		return session_name
	parts	= session_name.split('^', maxsplit=1)
	return parts[1] if len(parts)>1 else parts[0]

## Windows下增加一个session
def _add_new_session_windows(session, font_name='Fixedsys', font_size=10):
	if os.name!='nt':
		return
	
	# Window
	session_name	= _get_session_name_from_session(session)
	# 创建记录
	session_key	= winreg.CreateKey(winreg.HKEY_CURRENT_USER, f"{PUTTY_REGISTRY_PATH}\\{session_name}")

	if not session['key']:
		# 没有key则创建key
		print(f"create key for session {session_name}")
		session['key']	= generate_random_key()

	# 生成ppk私钥
	ppk_file	= os.path.join(PPK_path, session_name+'.ppk')
	generate_ppk_file(ppk_file, session['key'])

	# 生成OPENSSH私钥
	OPENSSH_file	= os.path.join(OPENSSH_path, session_name+'.key')
	generate_OPENSSH_file(OPENSSH_file, session['key'])
	
	# 写入默认的配置值
	for k in PUTTY_CONFIG_DEFAULT:
		# 不是字符串就只可能是DWORD了
		winreg.SetValueEx(session_key, k, 0, winreg.REG_SZ if isinstance(PUTTY_CONFIG_DEFAULT[k], str) else winreg.REG_DWORD, PUTTY_CONFIG_DEFAULT[k])

	# 写入/修改每个session的特有配置值
	winreg.SetValueEx(session_key, 'HostName',	0,	winreg.REG_SZ,		str(session['address'])	)	# 主机地址
	winreg.SetValueEx(session_key, 'PortNumber',	0,	winreg.REG_DWORD,	session['port']		)	# 端口
	winreg.SetValueEx(session_key, 'UserName',	0,	winreg.REG_SZ,		session['user']		)	# 用户名
	winreg.SetValueEx(session_key, 'PublicKeyFile',	0,	winreg.REG_SZ,		ppk_file		)	# PPK文件位置
	winreg.SetValueEx(session_key, 'WinTitle',	0,	winreg.REG_SZ,		session_name		)	# 窗口标题
	winreg.SetValueEx(session_key, 'Font',		0,	winreg.REG_SZ,		font_name		)	# 字体名称
	winreg.SetValueEx(session_key, 'FontHeight',	0,	winreg.REG_DWORD,	font_size		)	# 字体大小
	
	winreg.CloseKey(session_key)
	
######################################## 导出函数 ########################################

# ---------- key ----------

## 生成随机密码
def generate_random_password(length=20, alphanumeric=False):
	# 符号不包括引号（单双反）和圆括号以及冒号
	symbols	= r"!#$%&*+,-./;<=>?@[]^_{|}~"
	alphabet	= string.ascii_letters + string.digits + ('' if alphanumeric else symbols)
	return ''.join(secrets.choice(alphabet) for _ in range(length))

## 生成随机密钥
#	algorithm	密钥算法，默认为ed25519
# 返回key字典{key_type, private, public, comment}
def generate_random_key(algorithm='ssh-ed25519', comment=''):
	# 生成ed25519密钥
	key	= SigningKey.generate()
	verify	= key.verify_key

	# 生成密钥bytes
	private_key	= key.encode(nacl_RawEncoder)
	public_key	= verify.encode(nacl_RawEncoder)

	return {
		# 密钥类型
		'key_type'	: algorithm,
		# 密钥base64编码
		'private'	: base64.b64encode(_add_header_big_Endian_bytes(private_key)).decode('ascii'),
		# 算法名称+密钥，然后base64编码
		'public'	: base64.b64encode(_add_header_big_Endian_bytes(algorithm)+_add_header_big_Endian_bytes(public_key)).decode('ascii'),
		# 说明
		'comment'	: comment,
	}

## 根据公钥和私钥生成ppk文件
#	filename	ppk文件名，含路径
#	key		{key_type, private, public, comment}
def generate_ppk_file(filename, key):
	# 将字符串换成字节数组
	public	= key['public'].encode('ascii')
	private	= key['private'].encode('ascii')

	# 公钥按64字节一行分成多行
	public_lines	= [public[i:i+64].decode('ascii')+'\n' for i in range(0, len(public), 64)]

	# 私钥按64字节一行分成多行
	private_lines	= [private[i:i+64].decode('ascii')+'\n' for i in range(0, len(private), 64)]

	# 计算MAC，注意私钥和公钥要base64解码
	data	= _add_header_big_Endian_bytes('ssh-ed25519')			+\
		  _add_header_big_Endian_bytes('none')				+\
		  _add_header_big_Endian_bytes(key['comment'])			+\
		  _add_header_big_Endian_bytes(base64.b64decode(public))	+\
		  _add_header_big_Endian_bytes(base64.b64decode(private))
	MAC	= hmac.new(b'', data, hashlib.sha256).hexdigest()

	# 写入到文件
	try:
		with open(filename, 'w', encoding='utf-8') as f:
			f.write("PuTTY-User-Key-File-3: ssh-ed25519\n")
			f.write("Encryption: none\n")
			f.write(f"Comment: {key['comment']}\n")
			f.write(f"Public-Lines: {len(public_lines)}\n")
			f.writelines(public_lines)
			f.write(f"Private-Lines: {len(private_lines)}\n")
			f.writelines(private_lines)
			f.write(f"Private-MAC: {MAC}\n")
	except Exception as e:
		print(e)

## 生成OPEN_SSH私钥文件（仅支持ed25519)
#	filename	ppk文件名，含路径
#	key		{key_type, private, public, comment}
def generate_OPENSSH_file(filename, key): 
	# OpenSSH密钥格式 ed25519 private v1密钥格式，参考：https://sshref.dev/
	# 0.0 "openssh-key-v1" string plus terminating nullbyte (15 bytes)
	# 1.0 uint32 allocator for 1.0.0 (4 bytes)
	#     1.0.0 cipher name string (ASCII bytes)
	# 2.0 uint32 allocator for 2.0.0 (4 bytes)
	#     2.0.0 KDF name string (ASCII bytes)
	# 3.0 uint32 allocator for KDF options (3.0.0 to 3.0.1) (4 bytes) (ALWAYS 0 for unencrypted keys, so no following substructure)
	# 4.0 uint32 counter for # of keys (4 bytes)
	#     4.0.0 uint32 allocator for public key #n (4.0.0.0 to 4.0.0.1) (4 bytes)
	#         4.0.0.0 uint32 allocator for 4.0.0.0.0 (4 bytes)
	#             4.0.0.0.0 public key #n keytype string (ASCII bytes)
	#         4.0.0.1 uint32 allocator for 4.0.0.1.0 (4 bytes)
	#             4.0.0.1.0 public key #n payload (bytes)
	#     4.0.1 uint32 allocator for private key structure #n (4.0.1.0 to 4.0.1.5) (4 bytes)
	#         4.0.1.0 uint32 decryption "checksum" #1 (should match 4.0.1.1) (4 bytes)
	#         4.0.1.1 uint32 decryption "checksum" #2 (should match 4.0.1.0) (4 bytes)
	#         4.0.1.2 Copy of 4.0.0.0; allocator for 4.0.1.2.0 (4 bytes)
	#             4.0.1.2.0 Copy of 4.0.0.0.0 (ASCII bytes)
	#         4.0.1.3 Copy of 4.0.0.1; allocator for 4.0.1.3.0 (4 bytes)
	#             4.0.1.3.0 Copy of 4.0.0.1.0 (bytes)
	#         4.0.1.4 uint32 allocator for 4.0.1.4.0 (4 bytes)
	#             4.0.1.4.0 Private key #n (bytes)
	#         4.0.1.5 uint32 allocator for 4.0.1.5.0 (4 bytes)
	#             4.0.1.5.0 comment for key #n string (ASCII bytes)
	#         4.0.1.6 sequential padding

	key_n		= 1				# 密钥数量 (4.0 uint32 counter for # of keys)
	
	# 解算base64到bytes，注意公钥字节数组内是两个部分：类型+公钥，每个部分前面有4字节big endian长度
	public_bytes		= base64.b64decode(key['public'])
	# 实际的公钥数据字节：去掉
	public_key_bytes	= public_bytes[(int.from_bytes(public_bytes[:4], 'big')+4+4):]

	# 解算base64到bytes
	private_bytes		= base64.b64decode(key['private'])

	# 校验和1：未使用 (4.0.1.0 uint32 decryption "checksum" #1)
	# 校验和2：未使用 (4.0.1.1 uint32 decryption "checksum" #2)
	private_key_bytes	= _add_header_big_Endian_bytes('')+_add_header_big_Endian_bytes('')
	# 算法名（ssh-ed25519）(4.0.1.2.0 Copy of 4.0.0.0.0)
	# 公钥内容32字节 (4.0.1.3.0 Copy of 4.0.0.1.0)
	private_key_bytes	+=  public_bytes
	# 私钥内容64字节 (4.0.1.4.0 Private key #n (bytes))
	private_key_bytes	+=   _add_header_big_Endian_bytes(private_bytes[4:]+public_key_bytes)
	# 私钥注释 (4.0.1.5.0 comment for key #n string)
	private_key_bytes	+=  _add_header_big_Endian_bytes(key['comment'])
	# sequential padding填充，私钥数据长度必须是8的倍数 (4.0.1.6 sequential padding)
	padding_length	= 8 - (len(private_key_bytes) % 8)
	if padding_length<8:
		private_key_bytes	+= bytes(range(1, 1+padding_length))	# 填充1,2,3...padding_length
	
	key_bytes	=  b'openssh-key-v1\0'			# OpenSSH密钥标识 (0.0 "openssh-key-v1" string plus terminating nullbyte)
	key_bytes	+= _add_header_big_Endian_bytes('none')		# 加密算法：无 (1.0 uint32 allocator for 1.0.0 cipher name string)
	key_bytes	+= _add_header_big_Endian_bytes('none')		# KDF算法：无 (2.0 uint32 allocator for 2.0.0 KDF name string)
	key_bytes	+= _add_header_big_Endian_bytes('')			# KDF选项 (3.0 uint32 allocator for KDF options, ALWAYS 0 for unencrypted keys)
	key_bytes	+= key_n.to_bytes(4, 'big')		# 密钥数量
	key_bytes	+= _add_header_big_Endian_bytes(public_bytes)	# 公钥
	key_bytes	+= _add_header_big_Endian_bytes(private_key_bytes)	# 私钥

	# 编码成base64字符串
	key_string	= base64.b64encode(key_bytes).decode('ascii')

	# 写入文件，需要指定换行符为\n，即使在windows系统下也应该生成unix换行符风格
	with open(filename, 'w', encoding='utf-8', newline='\n') as f:
		f.write("-----BEGIN OPENSSH PRIVATE KEY-----\n")
		f.write('\n'.join(key_string[i:i+70] for i in range(0, len(key_string), 70)))
		f.write("\n-----END OPENSSH PRIVATE KEY-----\n")

# ---------- session ----------

## 获取当前的session：Windows从注册表读取PuTTY的session；Linux从~/.ssh/config读取Host
# 返回{
#	session_name
#	hostname
#	address
#	port
#	user
#	key_file
# }
def get_sessions():
	sessions	= {}

	match os.name:
		case 'nt':
			# windows
			try:
				# 读取putty的所有session的注册表
				session_root_key	= winreg.OpenKey(winreg.HKEY_CURRENT_USER, PUTTY_REGISTRY_PATH)
				subkeys_n, _, _	= winreg.QueryInfoKey(session_root_key)
				for i in range(subkeys_n):
					# 每个subkey是一个session，只取关键的配置
					session_name	= winreg.EnumKey(session_root_key, i)

					session_key	= winreg.OpenKey(winreg.HKEY_CURRENT_USER, f"{PUTTY_REGISTRY_PATH}\\{session_name}")
					s		= {'hostname':_get_hostname_from_sesion_name(session_name)}
					cs		= {
						"HostName"	: 'address',		# 主机地址
						"PortNumber"	: 'port',		# 端口
						"UserName"	: 'user',		# 用户名
					}
					for c in cs:
						s[cs[c]], _	= winreg.QueryValueEx(session_key, c)

					# 密钥文件
					ppk_file, _	= winreg.QueryValueEx(session_key, "PublicKeyFile")

					winreg.CloseKey(session_key)
					
					# 尝试从ppk文件提取密钥
					s['key']	= _parse_ppk_file(ppk_file)

					# 校验session名称格式：
					if _get_session_name_from_session(s)==session_name:
						sessions[session_name]	= s
				winreg.CloseKey(session_root_key)
			except Exception as e:
				print(f"读取注册表中的sessions列表失败: {e}") 
		case 'posix':
			# Linux
			SSH_config_file	= os.path.join(USER_SSH_BASE, 'config')
			if not os.path.exists(SSH_config_file):
				print(f"no ssh config file: {SSH_config_file}")
				return sessions
			
			with open(SSH_config_file, encoding='utf-8') as f:
				hosts	= re.split(r'Host\s', '\n'.join(f.readlines()))
				for host in hosts:
					parts		= host.split('\n')
					session_name	= parts[0].strip()
					if not session_name:
						continue

					s	= {'hostname':_get_hostname_from_sesion_name(session_name)}
					for part in parts[1:]:
						if not part:
							continue
						key_value		= part.split(maxsplit=1)
						match key_value[0]:
							case 'HostName':
								s['address']	= key_value[1]
							case 'Port':
								s['port']	= key_value[1]
							case 'User':
								s['user']	= key_value[1]
							case 'IdentityFile':
								key_file	= key_value[1]
								# 尝试从ppk文件提取密钥
								s['key']	= _parse_key_file(key_file)
					# 校验名称，检查是否包含了session的各个键，满足则添加此session
					if all(k in s for k in ['hostname', 'address', 'port', 'user', 'key']) and _get_session_name_from_session(s)==session_name:
						sessions[session_name]	= s
						
		case 'java':
			# Android等java虚拟机
			print(f"Java machine not supported")
		case _:
			print(f"{os.name} not supported")
	return sessions

## 获得指定session_name的session
def get_session_from_name(session_name, need_key=False):
	session	= None
	match os.name:
		case 'nt':
			# windows
			try:
				# 读取putty的所有session的注册表
				session_root_key	= winreg.OpenKey(winreg.HKEY_CURRENT_USER, PUTTY_REGISTRY_PATH)
				subkeys_n, _, _	= winreg.QueryInfoKey(session_root_key)
				for i in range(subkeys_n):
					# 每个subkey是一个session，只取关键的配置
					if session_name==winreg.EnumKey(session_root_key, i):
						# 找到指定名称的session了
						session_key	= winreg.OpenKey(winreg.HKEY_CURRENT_USER, f"{PUTTY_REGISTRY_PATH}\\{session_name}")
						session		= {'hostname':_get_hostname_from_sesion_name(session_name)}
						cs		= {
							"HostName"	: 'address',		# 主机地址
							"PortNumber"	: 'port',		# 端口
							"UserName"	: 'user',		# 用户名
						}
						for c in cs:
							session[cs[c]], _	= winreg.QueryValueEx(session_key, c)

						# 密钥文件
						ppk_file, _	= winreg.QueryValueEx(session_key, "PublicKeyFile")

						# 尝试从ppk文件提取密钥
						session['key']	= _parse_ppk_file(ppk_file)

						winreg.CloseKey(session_key)
					if session:
						return session
				winreg.CloseKey(session_root_key)
			except Exception as e:
				print(f"读取注册表中的sessions列表失败: {e}") 
		case 'posix':
			# Linux
			SSH_config_file	= os.path.join(USER_SSH_BASE, 'config')
			if not os.path.exists(SSH_config_file):
				print(f"no ssh config file: {SSH_config_file}")
				return session
			
			with open(SSH_config_file, encoding='utf-8') as f:
				hosts	= re.split(r'Host\s', '\n'.join(f.readlines()))
				for host in hosts:
					parts		= host.split('\n')
					if not parts[0].strip() or session_name!=parts[0].strip():
						continue

					session	= {'hostname':_get_hostname_from_sesion_name(session_name)}
					for part in parts[1:]:
						if not part:
							continue
						key_value		= part.split(maxsplit=1)
						match key_value[0]:
							case 'HostName':
								session['address']	= key_value[1]
							case 'Port':
								session['port']		= key_value[1]
							case 'User':
								session['user']		= key_value[1]
							case 'IdentityFile':
								key_file		= key_value[1]
								# 尝试从ppk文件提取密钥
								session['key']		= _parse_key_file(key_file)
					# 校验名称，检查是否包含了session的各个键，满足则添加此session
					if all(k in session for k in ['hostname', 'address', 'port', 'user', 'key']) and _get_session_name_from_session(s)==session_name:
						# 条件都满足
						return session
						
		case 'java':
			# Android等java虚拟机
			print(f"Java machine not supported")
		case _:
			print(f"{os.name} not supported")
	return session

## 添加指定的sessions到系统
#	sessions	要添加的session字典
#	overwrite	是否覆盖系统中已有的session
def add_sessions(sessions, overwrite=True, font_name='Fixedsys', font_size=10):
	current_sessions	= get_sessions()

	for session_name in sessions:
		session	= sessions[session_name]
		if session_name in current_sessions and overwrite==False:
			# 同名不覆盖，跳过
			continue
		# 添加
		if os.name=='nt':
			# Windows
			_add_new_session_windows(session, font_name, font_size)
		else:
			# TODO
			pass

## 从excel文件读取sessions
def load_sessions(session_file, check_key_file=False):
	try:
		book	= SpiritLong_excel.open_xlsx(session_file)
		records	= SpiritLong_excel.get_records_with_title(book['HOST'])
	except Exception as e:
		print(f"无法读取session文件{session_file}: {e}")
		return

	sessions	= {}
	for r in records:
		session	= {
			'hostname'	: r['HOSTNAME'],
			'address'	: str(r['ADDRESS']),
			'port'		: r['PORT'],
			'user'		: r['USER'],
		}

		session['key']	= {
			'key_type'	: 'ssh-ed25519',
			'private'	: r['PRIVATE'],
			'public'	: r['PUBLIC'],
			'comment'	: f"{r['USER']}@{r['HOSTNAME']}",
		} if r['PUBLIC'] and r['PRIVATE'] else None

		# session名称
		session_name	= _get_session_name_from_session(session)

		# 检验key_file：是否存在？和密钥文件是否一致？
		if check_key_file:
			if os.name=='nt':
				# Windows下才检验PPK文件
				ppk_file	= os.path.join(PPK_path, session_name+'.ppk')
				key		= _parse_ppk_file(ppk_file)

				if not key:
					print(f"{session_name} ppk key file not exist or error: {ppk_file}")
				elif key['public']!=session['key']['public'] or key['private']!=session['key']['private']:
					print(f"{session_name}: key and ppk file not match: {ppk_file}")
			else:
				# OPENSSH文件
				openssh_file	= os.path.join(OPENSSH_path, session_name+'.key')
				key		= _parse_key_file(openssh_file)
				if not key:
					print(f"{session_name} ppk key file not exist or error: {openssh_file}")
				elif key['public']!=session['key']['public'] or key['private']!=session['key']['private']:
					print(f"{session_name}: key and ppk file not match: {openssh_file}")
		# 添加到sessions
		sessions[session_name]	= session
		
	return sessions

## 把sessions写入到文件
def save_sessions(session_file, sessions=None):
	if sessions is None:
		# 获取当前的sessions
		sessions	= get_sessions()

	records	= []
	for session_name in sessions:
		s	= sessions[session_name]
				
		# 检验密钥文件
		# 检验key_file：是否存在？和密钥文件是否一致？
		if os.name=='nt':
			# Windows下才检验PPK文件
			ppk_file	= os.path.join(PPK_path, session_name+'.ppk')
			key		= _parse_ppk_file(ppk_file)
			session_key	= s['key']
			if not key:
				print(f"{session_name} ppk key file not exist or error: {ppk_file}")
			elif key['public']!=session_key['public'] or key['private']!=session_key['private']:
				print(f"{session_name}: key and ppk file not match: {ppk_file}")
				key	= None
		else:
			# OPENSSH文件
			openssh_file	= os.path.join(OPENSSH_path, session_name+'.key')
			key	= _parse_key_file(openssh_file)
			if not key:
				print(f"{session_name} ppk key file not exist or error: {openssh_file}")
			elif key['public']!=session_key['public'] or key['private']!=session_key['private']:
				print(f"{session_name}: key and openssh file not match: {openssh_file}")
				key	= None
			
		records.append({
			'ADDRESS'	: s['address'],
			'HOSTNAME'	: s['hostname'],
			'PORT'		: s['port'],
			'USER'		: s['user'],
			'PRIVATE'	: key['private'] if key else None,
			'PUBLIC'	: key['public'] if key else None,
		})

	# 写入到excel
	SpiritLong_excel.records_to_excel(session_file, {
		'HOST'	: {
			# 确保按此顺序放置各列
			'titles'	: ['USER', 'HOSTNAME', 'ADDRESS', 'PORT', 'PUBLIC', 'PRIVATE'],
			'records'	: records,
		}
	})

## 导入sessions到系统，原有的系统session将会被清空并重建密钥文件
#	session_file	要导入的session文件，默认None将会重新生成全部密钥文件
#	font_name	PuTTY字体
#	font_size	PuTTY字号大小
def import_sessions(session_file=None, font_name='Fixedsys', font_size=10):
	sessions	= load_sessions(session_file)
	if not sessions:
		print("no session found")
		return

	if os.name=='nt':
		# Windows
		# 清空当前所有session
		# 由于无法直接删除带子键的键，因此需要遍历。注意，需要尝试创建PUTTY_REGISTRY_PATH，若已存在则会打开
		try:
			session_root_key	= winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, PUTTY_REGISTRY_PATH, access=winreg.KEY_ALL_ACCESS)
			subkeys_n, _, _		= winreg.QueryInfoKey(session_root_key)
			session_names		= []
			for i in range(subkeys_n):
				# Session键中应该没有子键
				session_names.append(winreg.EnumKey(session_root_key, i))
			for s in session_names:
				session_key	= winreg.OpenKey(winreg.HKEY_CURRENT_USER, f"{PUTTY_REGISTRY_PATH}\\{s}")
				winreg.DeleteKeyEx(session_key, '')
			winreg.CloseKey(session_root_key)
		except Exception as e:
			print(f"清空sessions失败：{e}")

		# 清空所有密钥文件
		for f in os.listdir(OPENSSH_path):
			os.remove(os.path.join(OPENSSH_path, f))
		for f in os.listdir(PPK_path):
			os.remove(os.path.join(PPK_path, f))
		
		# 写入sessions
		for s in sessions:
			session		= sessions[s]
			_add_new_session_windows(session, font_name, font_size)

	elif os.name=='posix':
		# Linux
		config_file	= os.path.join(OPENSSH_path, 'config')
		if append==False:
			# 清空session
			os.remove(config_file)

			# 清空所有密钥文件
			os.removedirs(OPENSSH_path)
			os.removedirs(PPK_path)
		
		with open(config_file, "a+") as f:
			for s in sessions:
				# session名称
				session_name	= _get_session_name_from_session(s)

				# 生成OPENSSH私钥
				OPENSSH_file	= os.path.join(OPENSSH_path, session_name+'.key')
				generate_OPENSSH_file(OPENSSH_file, s['key'])

				# 写入配置
				f.write(f'''Host {session_name}
	HostName	{s['ADDRESS']}
	Port		{s['PORT']}
	User		{s['USER']}
	IdentityFile	{OPENSSH_file}
''')


## 导出系统sessions
def export_sessions(session_file):
	save_sessions(session_file)

## 导入密钥
#	session_name	名称
#	key_file	None表示生成密钥
def import_key(session_name, key_file=None):
	sessions	= get_sessions()
	if session_name not in sessions:
		print(f"no session named {session_name}")
		return

	session	= sessions[session_name]

	# 需确保parse_method返回的一定是key字典
	def try_parse_file(parse_method):
		try:
			key	= parse_method(key_file)
			if key['key_type']=='ssh-ed25519':
				# 只接受ed25519
				return key
			else:
				print(f"Failed: ed25519 only: {key_file}")
		except Exception as e:
			print(e)
		return None
	
	if key_file is None:
		# 生成密钥
		key	= generate_random_key()
	elif key_file.endswith('.ppk'):
		# ppk私钥文件
		key	=  try_parse_file(_parse_ppk_file)
	else:
		# 其他都视为OPENSSH密钥
		key	=  try_parse_file(_parse_key_file)

	if key:
		# 更新对应的key文件
		if os.name=='nt':
			generate_ppk_file(os.join(PPK_path, session_name+'.ppk'), key)
		generate_OPENSSH_file(os.join(OPENSSH_path, session_name+'.key'), key)

# ---------- host ----------
## 根据host文件更新主机上root认证：公钥（/root/.ssh/authorized_keys）和密码（可选），session的root公钥会自动添加上去，自动更新host的address/port
#	host_file	host文件
#	update_password	是否更新root密码
#	password_length	随机密码长度
def update_host_root(host_file, update_password=False, password_length=32):
	# 读取host_file
	hosts	= SpiritLong_excel.get_records_with_title_from_xlsx(host_file, 'HOST')
	
	# roots需要按host分类
	records	= SpiritLong_excel.get_records_with_title_from_xlsx(host_file, 'ROOT')
	roots	= {}
	for r in records:
		if r['HOSTNAME'] not in roots:
			roots[r['HOSTNAME']]	= []
		roots[r['HOSTNAME']].append(r)
	
	# 获取当前系统sessions
	sessions	= get_sessions()

	print("update_host_root:")
	for hostname in roots:
		# 在sessions中寻找此host
		result	= [sessions[s] for s in sessions if sessions[s]['hostname']==hostname and sessions[s]['user']=='root']
		if not result:
			print(f"no session for host {hostname}")
			continue

		session	= result[0]

		content	= '\n'.join([f"{k['PUBLIC']} {k['COMMENT']}" for k in roots[hostname]])

		print(f"update root on {hostname:30}", end='')
		
		# 更新/root/.ssh/authorized_keys
		result	= execute_on_session(_get_session_name_from_session(session), f'echo "{content}" >/root/.ssh/authorized_keys')
		if result=='':
			# 返回空表示成功
			print("OK")
		
	# 更新root密码
	if update_password:
		print("update password")
		for h in hosts:
			hostname	= h['HOSTNAME']
			# hosts里面必定是root用户的session才能访问
			session_name	= hostname
			# 设置root密码
			print(f"update password on {hostname:30}", end='')
			# 注意password为None时将会生成
			result	= update_password_on_session(session_name, password=h['PASSWORD'], password_length=password_length, session_file=session_file)
			# 返回的是新密码
			if h['PASSWORD'] is None and len(result)==password_length:
				# 生成了新的随机密码
				h['PASSWORD']	= result

			if result==h['PASSWORD']:
				# 成功更新密码，注意返回None肯定是失败了
				h['PASSWORD']	= result
				print("OK")
			else:
				print(f"Failed {result}")
	
		# 如果跟新了密码就需要更新host文件
		root_records	= []
		for r in roots:
			root_records	+= roots[r]

		SpiritLong_excel.records_to_excel(host_file, {
			'HOST'	: {'records'	: hosts },
			'ROOT'	: {'records'	: root_records	}
		})

## 更新HOST文件中的root公钥（来自/root/.ssh/authorized_keys)，自动更新host的address/port
#	host_file	host文件
#	sessions	None表示从注册表获得sessions
def update_host_file(host_file):
	# 读取host_file中的HOST
	hosts	= SpiritLong_excel.get_records_with_title_from_xlsx(host_file, 'HOST')
	
	# 获得session
	sessions	= get_sessions()

	print("update_host_file:")
	roots	= []
	for host in hosts:
		# 在sessions中寻找此host
		result	= [sessions[s] for s in sessions if sessions[s]['hostname']==host['HOSTNAME'] and sessions[s]['user']=='root']
		if not result:
			print(f"no session for host {host['HOSTNAME']}")
			continue

		session	= result[0]

		# 更新hosts中的信息
		host['ADDRESS']	= session['address']
		host['PORT']	= session['port']

		print(f'read host {host['HOSTNAME']:30}', end='')

		# 获取host上的root公钥
		session_name	= _get_session_name_from_session(session)
		result	= execute_on_session(session_name, f"cat /root/.ssh/authorized_keys")
		if result == '连接主机失败':
			continue
		else:
			print("OK")
		keys	= result.split('\n')
				
		for k in keys:
			if not k or k=='连接主机失败':
				# 跳过空行，以及连接失败的情况
				continue
			parts	= k.split(maxsplit=2)
			# ROOT页中的PUBLIC列是密钥类型+公钥
			public	= f"{parts[0]} {parts[1]}"
			comment	= parts[2] if len(parts)>2 else ''
			roots.append({
				'HOSTNAME'	: host['HOSTNAME'],
				'PUBLIC'	: public,
				'COMMENT'	: comment,
			})
	
	# 写入到host文件
	SpiritLong_excel.records_to_excel(host_file, {
		'HOST'	: {'records'	: hosts },
		'ROOT'	: {'records'	: roots	}
	})

## 为指定用户增加指定主机上的root权限，生成用户名目录，其中有各主机的root私钥
#	username		用户名(仅英文)
#	user_session_file	用户session文件，公钥或私钥为空则生成
def update_host_root_by_user(username, user_session_file):
	sessions	= get_sessions()
	
	# 读取用户session文件
	user_sessions	= load_sessions(user_session_file)
	
	# 选择root用户
	root_sessions	= {s:v for s,v in user_sessions.items() if v['user']=='root'}


	print("update_host_root_by_user:")
	records	= []
	for session_name in root_sessions:
		session	= root_sessions[session_name]
		
		# 获取host上的root公钥
		result	= execute_on_session(session_name, f"cat /root/.ssh/authorized_keys")
		if result == '连接主机失败':
			continue
				
		# 密钥按评论建立字典，这将导致重复的comment被删除
		keys	= {}
		for k in result.split('\n'):
			if not k or not k.strip():
				continue

			parts		= k.split(maxsplit=2)
			# ROOT页中的PUBLIC列是密钥类型+公钥
			public		= f"{parts[0]} {parts[1]}"
			comment		= parts[2] if len(parts)>2 else ''
			keys[comment]	= public
		
		# 更新此用户的root key
		if not session['key']:
			# 需要生成密钥
			session['key']	= generate_random_key()
		keys[username]	= f"{session['key']['key_type']} {session['key']['public']}"

		# 更新root公钥
		content	= '\n'.join([f"{keys[c]} {c}" for c in keys])

		print(f"update root on {session['hostname']:30}", end='')
		
		# 更新/root/.ssh/authorized_keys
		result	= execute_on_session(session_name, f'echo "{content}" >/root/.ssh/authorized_keys')
		if result=='':
			# 返回空表示成功
			print("OK")

		records.append({
			'ADDRESS'	: session['address'],
			'HOSTNAME'	: session['hostname'],
			'PORT'		: session['port'],
			'USER'		: 'root',
			'PRIVATE'	: session['key']['private'],
			'PUBLIC'	: session['key']['public'],
		})

	# 生成session文件
	SpiritLong_excel.records_to_excel(user_session_file, {
		'HOST'	: {
			# 确保按此顺序放置各列
			'titles'	: ['USER', 'HOSTNAME', 'ADDRESS', 'PORT', 'PUBLIC', 'PRIVATE'],
			'records'	: records,
		}
	})

# -------------- upload/download/execute --------------
## 使用SFTP上传文件
def upload_on_server(address, port, username, password, local_file, remote_file, mode=0o600, OPENSSH_file=None):
	ssh	= _connect_server(address, port, username, password, OPENSSH_file)
	sftp	= ssh.open_sftp()
	sftp.put(local_file, remote_file)
	sftp.chmod(remote_file, mode)
	sftp.close()
	ssh.close()

## 使用SFTP下载文件，
def download_on_server(address, port, username, password, remote_file, local_file, OPENSSH_file=None):
	ssh	= _connect_server(address, port, username, password, OPENSSH_file)
	sftp	= ssh.open_sftp()
	sftp.get(remote_file, local_file)
	sftp.close()
	ssh.close()

## 远程连接执行非交互命令，返回stdout+stderr
def execute_on_server(address, port, username, password, command, OPENSSH_file=None):
	ssh	= _connect_server(address, port, username, password, OPENSSH_file)
	if not ssh:
		return "连接主机失败"
	stdin, stdout, stderr	= ssh.exec_command(command, False)
	
	stdout_content	= stdout.read().decode(encoding='utf-8')
	stderr_content	= stderr.read().decode(encoding='utf-8')

	ssh.close()
	return stdout_content+stderr_content

## 使用SFTP上传文件，使用session
def upload_on_session(session_name, local_file, remote_file, mode=0o600, OPENSSH_file=None):
	ssh	= _connect_session(session_name, OPENSSH_file)
	sftp	= ssh.open_sftp()
	sftp.put(local_file, remote_file)
	sftp.chmod(remote_file, mode)
	sftp.close()
	ssh.close()

## 使用SFTP下载文件，使用session
def download_on_session(session_name, remote_file, local_file, OPENSSH_file=None):
	ssh	= _connect_session(session_name, OPENSSH_file)
	sftp	= ssh.open_sftp()
	sftp.get(remote_file, local_file)
	sftp.close()
	ssh.close()

## 远程连接执行非交互命令，使用session，返回stdout+stderr
def execute_on_session(session_name, command, OPENSSH_file=None):
	ssh	= _connect_session(session_name, OPENSSH_file)
	if not ssh:
		return "连接主机失败"
	stdin, stdout, stderr	= ssh.exec_command(command, False)
	
	stdout_content	= stdout.read().decode(encoding='utf-8')
	stderr_content	= stderr.read().decode(encoding='utf-8')

	ssh.close()
	return stdout_content+stderr_content

# --------------- server --------------
## 测试sessions是否可以连接
def test_sessions_connectivity(sessions):
	max_length	= max([len(n) for n in sessions])
	for s in sessions:
		print(f'connecting {s:{max_length+5}}', end='')
		result	= execute_on_session(s, 'echo OK')
		if result=='OK\n':
			print("OK")

## 更新密码
def update_password_on_session(session_name, username='root', new_password=None, password_length=20, OPENSSH_file=None):
	if new_password is None:
		# 密码为空则将生成新密码，符号不包括引号（单双反）和圆括号以及冒号
		new_password	= generate_random_password(password_length)

	# 更新密码，注意bash中单引号将把$视为字符，而双引号将会视为shell变量
	result	= execute_on_session(session_name, f"echo '{username}:{new_password}' | chpasswd", OPENSSH_file=OPENSSH_file)
	# 返回空字符串表示成功，则返回新设置的密码
	return new_password if result=='' else None

## 导入指定的session到server
#	sessions_name	server的session
#	sessions	要导入的sessions
def import_sessions_to_server(session_name, sessions, server_username='root', overwrite=True):
	config_content	= []
	for s in sessions:
		print(f"preparing key of {s}")
		session		= sessions[s]
		key_filename	= _get_session_name_from_session(session)+'.key'
		key_on_server	= '/root/.ssh/'+key_filename
		config_content.append(f'''Host\t{_get_session_name_from_session(session)}
	HostName	{session['address']}
	Port		{session['port']}
	User		{session['user']}
	IdentityFile	{key_on_server}
''')		
		# 生成密钥
		generate_OPENSSH_file(key_filename, session['key'])

		# 上传密钥
		upload_on_session(session_name, key_filename, key_on_server, mode=0o600)

		# 删除本地临时密钥文件
		os.remove(key_filename)	

	# 在server上设置config文件		
	result	= execute_on_session(session_name, f'''
			mkdir -p ~/.ssh
			echo '{'\n'.join(config_content)}' > ~/.ssh/config
			echo $?
''')
	if result!='0\n':
		print(f"{session_name} execute failed")

if __name__ == '__main__':
	# 测试本机sessions是否可以连通
	test_sessions_connectivity(get_sessions())

	