diff --git a/app/lib/DnsHelper.php b/app/lib/DnsHelper.php index 8123edd..cb576d8 100644 --- a/app/lib/DnsHelper.php +++ b/app/lib/DnsHelper.php @@ -442,6 +442,41 @@ class DnsHelper 'page' => true, 'add' => false, ], + 'henet' => [ + 'name' => 'HE DNS', + 'icon' => 'he.ico', + 'note' => '', + 'config' => [ + 'username' => [ + 'name' => '用户名/邮箱', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'password' => [ + 'name' => '密码', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'remark' => 0, + 'status' => false, + 'redirect' => false, + 'log' => false, + 'weight' => false, + 'page' => true, + 'add' => false, + ], 'spaceship' => [ 'name' => 'Spaceship', 'icon' => 'spaceship.ico', @@ -699,6 +734,7 @@ class DnsHelper 'qingcloud' => ['DEF' => '0', 'CT' => '2', 'CU' => '3', 'CM' => '4', 'AB' => '8'], 'cloudflare' => ['DEF' => '0'], 'namesilo' => ['DEF' => 'default'], + 'henet' => ['DEF' => 'default'], 'powerdns' => ['DEF' => 'default'], 'spaceship' => ['DEF' => 'default'], 'aliyunesa' => ['DEF' => '0'], diff --git a/app/lib/dns/henet.php b/app/lib/dns/henet.php new file mode 100644 index 0000000..5d579af --- /dev/null +++ b/app/lib/dns/henet.php @@ -0,0 +1,562 @@ +username = $config['username']; + $this->password = $config['password']; + $this->domain = $config['domain']; + $this->domainid = $config['domainid']; + $this->proxy = isset($config['proxy']) ? $config['proxy'] == 1 : false; + $this->cacheKey = 'henet_cookie_' . md5($this->username . '|' . $this->password); + $this->loadCachedSession(); + } + + public function getError() + { + return $this->error; + } + + public function check() + { + return $this->getDomainList() !== false; + } + + public function getDomainList($KeyWord = null, $PageNumber = 1, $PageSize = 20) + { + $html = $this->login(); + if ($html === false) { + return false; + } + + $list = $this->parseDomains($html); + if (!isNullOrEmpty($KeyWord)) { + $list = array_values(array_filter($list, function ($row) use ($KeyWord) { + return stripos($row['Domain'], $KeyWord) !== false; + })); + } + + $total = count($list); + $offset = max(0, ($PageNumber - 1) * $PageSize); + $list = array_slice($list, $offset, $PageSize); + return ['total' => $total, 'list' => $list]; + } + + public function getDomainRecords($PageNumber = 1, $PageSize = 20, $KeyWord = null, $SubDomain = null, $Value = null, $Type = null, $Line = null, $Status = null) + { + $zoneid = $this->getZoneId(); + if ($zoneid === false) { + return false; + } + + $html = $this->request('GET', 'index.cgi?' . http_build_query([ + 'hosted_dns_zoneid' => $zoneid, + 'menu' => 'edit_zone', + 'hosted_dns_editzone' => '', + ])); + if ($html === false) { + return false; + } + + $list = $this->parseRecords($html); + if (!isNullOrEmpty($SubDomain)) { + $list = array_values(array_filter($list, function ($row) use ($SubDomain) { + return strcasecmp($row['Name'], $SubDomain) === 0; + })); + } else { + if (!isNullOrEmpty($KeyWord)) { + $list = array_values(array_filter($list, function ($row) use ($KeyWord) { + return stripos($row['Name'], $KeyWord) !== false || stripos($row['Value'], $KeyWord) !== false; + })); + } + if (!isNullOrEmpty($Value)) { + $list = array_values(array_filter($list, function ($row) use ($Value) { + return $row['Value'] == $Value; + })); + } + if (!isNullOrEmpty($Type)) { + $list = array_values(array_filter($list, function ($row) use ($Type) { + return strcasecmp($row['Type'], $Type) === 0; + })); + } + if (!isNullOrEmpty($Status)) { + $list = array_values(array_filter($list, function ($row) use ($Status) { + return $row['Status'] == $Status; + })); + } + } + + $total = count($list); + $offset = max(0, ($PageNumber - 1) * $PageSize); + $list = array_slice($list, $offset, $PageSize); + return ['total' => $total, 'list' => $list]; + } + + public function getSubDomainRecords($SubDomain, $PageNumber = 1, $PageSize = 20, $Type = null, $Line = null) + { + if ($SubDomain == '') $SubDomain = '@'; + return $this->getDomainRecords($PageNumber, $PageSize, null, $SubDomain, null, $Type, $Line); + } + + public function getDomainRecordInfo($RecordId) + { + $records = $this->getDomainRecords(1, 1000); + if ($records === false) { + return false; + } + foreach ($records['list'] as $row) { + if ($row['RecordId'] == $RecordId) { + return $row; + } + } + $this->setError('解析记录不存在'); + return false; + } + + public function addDomainRecord($Name, $Type, $Value, $Line = 'default', $TTL = 600, $MX = 1, $Weight = null, $Remark = null) + { + $zoneid = $this->getZoneId(); + if ($zoneid === false) { + return false; + } + + $params = $this->buildRecordParams($zoneid, '', $Name, $Type, $Value, $TTL, $MX); + $params['hosted_dns_editrecord'] = 'Submit'; + + $html = $this->request('POST', 'index.cgi', $params); + if ($html !== false && $this->isSuccess($html, 'Successfully added new record')) { + $recordid = $this->findRecordInHtml($html, $Name, $Type, $Value); + return $recordid ? $recordid : true; + } + + $this->setError($this->extractMessage($html) ?: '添加解析记录失败'); + return false; + } + + public function updateDomainRecord($RecordId, $Name, $Type, $Value, $Line = 'default', $TTL = 600, $MX = 1, $Weight = null, $Remark = null) + { + $zoneid = $this->getZoneId(); + if ($zoneid === false) { + return false; + } + + $params = $this->buildRecordParams($zoneid, $RecordId, $Name, $Type, $Value, $TTL, $MX); + $params['hosted_dns_editrecord'] = 'Update'; + + $html = $this->request('POST', 'index.cgi', $params); + if ($html !== false && $this->isSuccess($html, 'Successfully updated record')) { + return true; + } + + $this->setError($this->extractMessage($html) ?: '修改解析记录失败'); + return false; + } + + public function updateDomainRecordRemark($RecordId, $Remark) + { + return false; + } + + public function deleteDomainRecord($RecordId) + { + $zoneid = $this->getZoneId(); + if ($zoneid === false) { + return false; + } + + $html = $this->request('POST', 'index.cgi', [ + 'menu' => 'edit_zone', + 'hosted_dns_zoneid' => $zoneid, + 'hosted_dns_recordid' => $RecordId, + 'hosted_dns_editzone' => '1', + 'hosted_dns_delrecord' => '1', + 'hosted_dns_delconfirm' => 'delete', + ]); + if ($html !== false && $this->isSuccess($html, 'Successfully removed record')) { + return true; + } + + $this->setError($this->extractMessage($html) ?: '删除解析记录失败'); + return false; + } + + public function setDomainRecordStatus($RecordId, $Status) + { + return false; + } + + public function getDomainRecordLog($PageNumber = 1, $PageSize = 20, $KeyWord = null, $StartDate = null, $endDate = null) + { + return false; + } + + public function getRecordLine() + { + return ['default' => ['name' => '默认', 'parent' => null]]; + } + + public function getMinTTL() + { + return 300; + } + + public function addDomain($Domain) + { + return false; + } + + private function login() + { + if ($this->loggedIn) { + return $this->request('GET', ''); + } + + $this->request('GET', '', null, false); + $html = $this->request('POST', '', [ + 'email' => $this->username, + 'pass' => $this->password, + ], false); + if ($html === false) { + return false; + } + if (stripos($html, 'incorrect') !== false || stripos($html, 'Invalid') !== false) { + $this->setError($this->extractMessage($html) ?: '登录失败,请检查用户名和密码'); + return false; + } + + $domains = $this->parseDomains($html); + if (empty($domains) && stripos($html, 'hosted_dns_zoneid') === false) { + $this->setError($this->extractMessage($html) ?: '登录失败,未找到域名列表'); + return false; + } + + $this->loggedIn = true; + $this->saveCachedSession(); + return $html; + } + + private function getZoneId() + { + if (!isNullOrEmpty($this->domainid)) { + return $this->domainid; + } + if (isNullOrEmpty($this->domain)) { + $this->setError('未指定域名'); + return false; + } + + $domains = $this->getDomainList($this->domain, 1, 1000); + if ($domains === false) { + return false; + } + foreach ($domains['list'] as $row) { + if (strcasecmp($row['Domain'], $this->domain) === 0) { + $this->domainid = $row['DomainId']; + return $this->domainid; + } + } + + $this->setError('域名不存在或无权限访问'); + return false; + } + + private function buildRecordParams($zoneid, $recordid, $Name, $Type, $Value, $TTL, $MX) + { + return [ + 'account' => '', + 'menu' => 'edit_zone', + 'Type' => strtoupper($Type), + 'hosted_dns_zoneid' => $zoneid, + 'hosted_dns_recordid' => $recordid, + 'hosted_dns_editzone' => '1', + 'Priority' => strtoupper($Type) == 'MX' ? intval($MX) : '', + 'Name' => $this->toFullName($Name), + 'Content' => $this->convertValue($Value, $Type), + 'TTL' => intval($TTL), + ]; + } + + private function parseDomains($html) + { + $list = []; + $patterns = [ + '/onclick="delete_dom\(this\);"[^>]*name="([^"]+)"[^>]*value="(\d+)"/i', + '/name="([^"]+)"[^>]*value="(\d+)"[^>]*onclick="delete_dom\(this\);"/i', + ]; + foreach ($patterns as $pattern) { + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER)) { + continue; + } + foreach ($matches as $match) { + $domain = html_entity_decode($match[1], ENT_QUOTES); + $zoneid = $match[2]; + $list[$zoneid] = [ + 'DomainId' => $zoneid, + 'Domain' => $domain, + 'RecordCount' => 0, + ]; + } + } + if (preg_match_all('/hosted_dns_zoneid=(\d+)[^"\']*["\'][^>]*>\s*([^<]+)\s* $match[1], + 'Domain' => $domain, + 'RecordCount' => 0, + ]; + } + } + } + return array_values($list); + } + + private function parseRecords($html) + { + $list = []; + if (!preg_match_all('/]*>.*?<\/tr>/is', $html, $rows, PREG_SET_ORDER)) { + return $list; + } + + foreach ($rows as $row) { + $cells = $this->extractCells($row[0]); + if (count($cells) < 4) continue; + $ttl = is_numeric($cells[4]) ? intval($cells[4]) : 0; + $name = $cells[2]; + $type = strtoupper($cells[3]); + $value = $cells[6]; + $priority = is_numeric($cells[5]) ? intval($cells[5]) : null; + + $list[] = [ + 'RecordId' => $cells[1], + 'Domain' => $this->domain, + 'Name' => $this->fromFullName($name), + 'Type' => $type, + 'Value' => $this->normalizeValue($value, $type), + 'Line' => 'default', + 'TTL' => $ttl, + 'MX' => $priority, + 'Status' => stripos($row[0], 'disabled') !== false ? '0' : '1', + 'Weight' => null, + 'Remark' => null, + 'UpdateTime' => null, + ]; + } + + return $list; + } + + private function extractCells($html) + { + $cells = []; + if (preg_match_all('/]*>(.*?)<\/td>/is', $html, $matches)) { + foreach ($matches[1] as $cell) { + $cell = preg_replace('/]*>.*?<\/script>/is', '', $cell); + $cell = preg_replace('/]*>.*?<\/style>/is', '', $cell); + $cell = trim(html_entity_decode(strip_tags($cell), ENT_QUOTES)); + $cell = preg_replace('/\s+/', ' ', $cell); + if ($cell !== '') { + $cells[] = $cell; + } + } + } + return $cells; + } + + private function findRecordInHtml($html, $Name, $Type, $Value) + { + $records = $this->parseRecords($html); + $name = $this->fromFullName($this->toFullName($Name)); + $value = $this->normalizeValue($this->convertValue($Value, $Type), $Type); + foreach ($records as $record) { + if ( + strcasecmp($record['Name'], $name) === 0 && + strcasecmp($record['Type'], $Type) === 0 && + $record['Value'] == $value + ) { + return $record; + } + } + return false; + } + + private function toFullName($name) + { + if ($name == '@' || $name == '') { + return $this->domain; + } + if (str_ends_with($name, '.' . $this->domain) || strcasecmp($name, $this->domain) === 0) { + return $name; + } + return $name . '.' . $this->domain; + } + + private function fromFullName($name) + { + $name = rtrim($name, '.'); + if (strcasecmp($name, $this->domain) === 0) { + return '@'; + } + if (str_ends_with(strtolower($name), '.' . strtolower($this->domain))) { + return substr($name, 0, -(strlen($this->domain) + 1)); + } + return $name; + } + + private function convertValue($value, $type) + { + if (strtoupper($type) == 'TXT') { + return trim($value, '"'); + } + return $value; + } + + private function normalizeValue($value, $type) + { + $value = trim($value); + if (strtoupper($type) == 'TXT') { + return trim($value, '"'); + } + return $value; + } + + private function isSuccess($html, $message) + { + return stripos($html, $message) !== false; + } + + private function extractMessage($html) + { + if (!$html) { + return null; + } + if (preg_match('/<(?:div|span)[^>]*class="[^"]*(?:error|warn|success|message)[^"]*"[^>]*>(.*?)<\/(?:div|span)>/is', $html, $match)) { + return trim(html_entity_decode(strip_tags($match[1]), ENT_QUOTES)); + } + if (preg_match('/(Successfully [^<]+|Error:[^<]+|Invalid [^<]+)/i', $html, $match)) { + return trim(html_entity_decode($match[1], ENT_QUOTES)); + } + return null; + } + + private function request($method, $path, $params = null, $requireLogin = true, $allowRelogin = true) + { + if ($requireLogin && !$this->loggedIn && $this->login() === false) { + return false; + } + + $url = str_starts_with($path, 'http') ? $path : $this->baseUrl . $path; + try { + $response = http_request($url, $params ? http_build_query($params) : null, $this->baseUrl, $this->cookie, null, $this->proxy, $method, 20); + } catch (Exception $e) { + $this->setError($e->getMessage()); + return false; + } + + $this->storeCookie($response['headers']); + if ($response['code'] >= 400) { + $this->setError('HTTP请求失败:' . $response['code']); + return false; + } + if ($requireLogin && $allowRelogin && $this->isLoginExpired($response['body'])) { + $this->clearCachedSession(); + if ($this->login() === false) { + return false; + } + return $this->request($method, $path, $params, true, false); + } + return $response['body']; + } + + private function storeCookie($headers) + { + $cookies = []; + if ($this->cookie !== '') { + foreach (explode('; ', $this->cookie) as $cookie) { + $parts = explode('=', $cookie, 2); + if (count($parts) == 2) { + $cookies[$parts[0]] = $parts[1]; + } + } + } + + foreach ($headers as $name => $values) { + if (strtolower($name) !== 'set-cookie') { + continue; + } + foreach ((array)$values as $value) { + $cookie = explode(';', $value, 2)[0]; + $parts = explode('=', $cookie, 2); + if (count($parts) == 2) { + $cookies[$parts[0]] = $parts[1]; + } + } + } + + $pairs = []; + foreach ($cookies as $name => $value) { + $pairs[] = $name . '=' . $value; + } + $this->cookie = implode('; ', $pairs); + if ($this->loggedIn && $this->cookie !== '') { + $this->saveCachedSession(); + } + } + + private function loadCachedSession() + { + $cookie = Cache::get($this->cacheKey); + if (is_string($cookie) && $cookie !== '') { + $this->cookie = $cookie; + $this->loggedIn = true; + } + } + + private function saveCachedSession() + { + if ($this->cookie !== '') { + Cache::set($this->cacheKey, $this->cookie, 3600); + } + } + + private function clearCachedSession() + { + $this->cookie = ''; + $this->loggedIn = false; + Cache::delete($this->cacheKey); + } + + private function isLoginExpired($html) + { + if (!$html) { + return false; + } + return stripos($html, 'name="email"') !== false + && stripos($html, 'name="pass"') !== false + && stripos($html, 'hosted_dns_zoneid') === false; + } + + private function setError($message) + { + $this->error = $message; + } +} diff --git a/public/static/images/he.ico b/public/static/images/he.ico new file mode 100644 index 0000000..5cdfc68 Binary files /dev/null and b/public/static/images/he.ico differ