diff --git a/README.md b/README.md index f2745bc..a6b4a1d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,21 @@ -# PMail +# PMail -> The current code is not stable, be sure to record the log! Lost letters or letters parsed wrong can find out the original content of the mail from the log! +> The current code is not stable, be sure to record the log! Lost letters or letters parsed wrong can find out the +> original content of the mail from the log! ## [中文文档](./README_CN.md) ## Introduction -An extremely lightweight mailbox server designed for personal use scenarios. +PMail is a personal email server that pursues a minimal deployment process and extreme resource consumption. It runs on +a single file and contains complete send/receive mail service and web-side mail management functions. Just a server , a +domain name , a line of code , a minute of deployment time , you will be able to build a domain name mailbox of your +own . + +Any project related Issue, PR is welcome.At present, the project UI design is ugly, UI interaction experience is poor, +welcome all UI, designers, front-end guidance. Finally, also for this project to solicit a beautiful and lovely Logo! + +Editor ## Features @@ -16,36 +25,29 @@ An extremely lightweight mailbox server designed for personal use scenarios. * Support dkim, spf checksum, [Email Test](https://www.mail-tester.com/) score 10 points if correctly configured. +* Implementing the ACME protocol, the program will automatically obtain and update Let's Encrypt certificates. + ## Disadvantages -* At present, only the core function of sending and receiving emails has been completed. Basically, it can only be used by a single person, and does not deal with issues related to permission management in the process of multiple users. +* At present, only the core function of sending and receiving emails has been completed. Basically, it can only be used + by a single person, and does not deal with issues related to permission management in the process of multiple users. * The UI is ugly # How to run -## 1、Generate DKIM secret key +## 1、Download -Generate public and private keys by the dkim-keygen tool of the [go-msgauth](https://github.com/emersion/go-msgauth) project +[Click Here](https://github.com/Jinnrry/PMail/releases) Download a program file that matches you. -Put the key in the `config/dkim` directory. +## 2、Run -## 2、Set DNS +`double-click to open` Or `execute command to run` -Add the following records to your domain DNS settings +## 3、Configuration -| type | hostname | address / value | -|------|----------------------|----------------------| -| A | smtp | server ip | -| MX | _ | smtp.YourDomain | -| TXT | _ | v=spf1 a mx ~all | -| TXT | default._domainkey | Your DKIM public key | - -## 3、Domain SSL Key - -Prepare the certificate of `smtp.YourDomain`, the private key in ".key" format and the public key in ".crt" format - -Put the certificate in the `config/ssl` directory. +Open `http://127.0.0.1` in your browser or use your server's public IP to visit, then follow the instructions to +configure. ## 4、Build(or download) @@ -59,35 +61,15 @@ Put the certificate in the `config/ssl` directory. Modify the `config.json` file in the config directory and fill in your secret key and domain information. -Tips: +## 6、Email Test -MySQL database name must is `pmail`, and charset must is `utf8_general_ci`. +Check if your mailbox has completed all the security configuration. It is recommended to +use [https://www.mail-tester.com/](https://www.mail-tester.com/) for checking. -Configuration file description : -```json -{ - "domain": "demo.com", // Your domain - "dkimPrivateKeyPath": "config/dkim/dkim.priv", // dkim private key - "SSLPrivateKeyPath": "config/ssl/private.key", // ssl private key - "SSLPublicKeyPath": "config/ssl/public.crt", // ssl public key - "mysqlDSN": "username:password@tcp(127.0.0.1:3306)/pmail?parseTime=True&loc=Local", // mysql connect infonation - "weChatPushAppId": "", // WeChat public account appid (for new email message push) . If you don't need it, you can make it empty. - "weChatPushSecret": "", // WeChat api secret - "weChatPushTemplateId": "", // push template id - "weChatPushUserId": "" // wechat user id -} -``` - -## 6、Run - -exec `pmail` and check port of 25、80. - -The webmail service address is http://yourip. Default account is `admin` and password is `admin` - -## 7、Email Test - -Check if your mailbox has completed all the security configuration. It is recommended to use [https://www.mail-tester.com/](https://www.mail-tester.com/) for checking. +## 7、 WeChat Message Push +Open the `config/config.json` file in the run directory, edit a few configuration items at the beginning of `weChatPush` +and restart the service. # For Developer @@ -104,7 +86,3 @@ The code is in `server` folder. ## Plugin Development Reference this file. `server/hooks/wechat_push/wechat_push.go` - -# What's More - -Welcome PR! Welcome Issues! The project need a Logo ! \ No newline at end of file diff --git a/README_CN.md b/README_CN.md index 40e0612..abd036e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -1,7 +1,13 @@ -# PMail +# PMail > Welcome PR! Welcome Issues! 目前代码并不稳定,一定记录好日志!丢信或者信件解析错误可以从日志中找出邮件原始内容! +PMail是一个追求极简部署流程、极致资源占用的个人域名邮箱服务器。单文件运行,包含完整的收发邮件服务和Web端邮件管理功能。只需一台服务器、一个域名、一行代码、一分钟部署时间,你就能够搭建出一个自己的域名邮箱。 + +目前项目UI设计比较丑陋、UI交互体验较差,欢迎各位UI、设计师、前端提出指导意见。最后,也为这个项目征集一个漂亮可爱的Logo! + +Editor + ## 为什么写这个项目 迫于越来越多的邮件服务商暂停了针对个人的域名邮箱服务(比如QQ邮箱、微软Outlook邮箱),因此考虑自建域名邮箱服务。 @@ -22,6 +28,10 @@ 支持dkim、spf校验。正确配置的情况下,Email Test得分10分。 +### 4、自动SSL证书 + +实现了ACME协议,程序将自动获取并更新Let’s Encrypt证书。 + ## 其他 ### 不足 @@ -35,78 +45,25 @@ # 如何部署 -## 1、生成DKIM 秘钥 +## 1、下载文件 -``` -go install github.com/emersion/go-msgauth/cmd/dkim-keygen@latest -dkim-keygen -``` -执行后将得到`dkim.priv`文件,公钥数据会直接输出 +[点击这里](https://github.com/Jinnrry/PMail/releases)下载一个与你匹配的程序文件。 -生成以后将密钥放到`config/dkim`目录中 +## 2、运行 -## 2、设置域名DNS +双击打开 OR 执行命令运行 -添加以下记录到你到域名解析中 +## 3、配置 -| 类型 | 主机记录 | 记录值 | -|-----|---------------------|------------------| -| A | smtp | 服务器IP | -| MX | _ | smtp.你的域名 | -| TXT | _ | v=spf1 a mx ~all | -| TXT | default._domainkey | 你生成的DKIM公钥 | +浏览器打开 `http://127.0.0.1` 或者是用你服务器公网IP访问,然后按提示配置 -## 3、申请域名证书 - -准备好 `smtp.你的域名` 的证书,key格式的私钥和crt格式的公钥 - -放到`config/ssl`目录中 - -## 4、编译程序(或者直接下载编译好的二进制文件) - -1、前端环境:安装好node环境,配置好yarn - -2、后端环境:安装最新的golang - -3、执行`./build.sh` - -## 5、修改配置文件 - -修改config目录中的`config.json`文件,填入你的秘钥与域名信息 - -Tips: - -MySQL库名必须叫pmail,另外,数据库必须使用utf8_general_ci字符集 - -配置文件说明: -```json -{ - "domain": "demo.com", // 你的域名 - "dkimPrivateKeyPath": "config/dkim/dkim.priv", // dkim私钥 - "SSLPrivateKeyPath": "config/ssl/private.key", // ssl证书私钥 - "SSLPublicKeyPath": "config/ssl/public.crt", // ssl证书公钥 - "mysqlDSN": "username:password@tcp(127.0.0.1:3306)/pmail?parseTime=True&loc=Local", // mysql连接信息 - "weChatPushAppId": "", //微信公众号id(用于新消息提醒),没有留空即可 - "weChatPushSecret": "", // 微信公众号api秘钥 - "weChatPushTemplateId": "", // 微信公众号推送模板id - "weChatPushUserId": "" // 微信推送用户id -} -``` - -## 6、启动 - -运行`PMail`程序,检查服务器25、80端口正常即可 - -邮箱后台, http://yourip,默认账号admin,默认密码admin - -## 7、邮箱得分测试 +## 4、邮箱得分测试 建议找一下邮箱测试服务(比如[https://www.mail-tester.com/](https://www.mail-tester.com/))进行邮件得分检测,避免自己某些步骤漏配,导致发件进对方垃圾箱。 -## 8、其他说明 - -邮件是否进对方垃圾箱与程序无关、与你的服务器IP、服务器域名有关。我自己搭建的服务,测试了收发QQ、Gmail、Outlook、163、126均正常,无任何拦截,且不会进垃圾箱。 +## 5、微信推送 +打开运行目录下的 `config/config.json`文件,编辑 `weChatPush` 开头的几个配置项,重启服务即可。 # 参与开发 @@ -124,6 +81,3 @@ MySQL库名必须叫pmail,另外,数据库必须使用utf8_general_ci字符 参考微信推送插件`server/hooks/wechat_push/wechat_push.go` -# 最后 - -欢迎PR! 欢迎Issue!求个Logo! \ No newline at end of file diff --git a/build.sh b/build.sh old mode 100755 new mode 100644 diff --git a/docs/cn.gif b/docs/cn.gif new file mode 100644 index 0000000..05e8f7d Binary files /dev/null and b/docs/cn.gif differ diff --git a/docs/en.gif b/docs/en.gif new file mode 100644 index 0000000..3f9e45c Binary files /dev/null and b/docs/en.gif differ diff --git a/fe/src/http/http.js b/fe/src/http/http.js index e7ffd82..a326fe5 100644 --- a/fe/src/http/http.js +++ b/fe/src/http/http.js @@ -6,7 +6,7 @@ import lang from '../i18n/i18n'; //创建axios的一个实例 var $http = axios.create({ baseURL: import.meta.env.VITE_APP_URL, //接口统一域名 - timeout: 6000, //设置超时 + timeout: 60000, //设置超时 headers: { 'Content-Type': 'application/json;charset=UTF-8;', 'Lang': lang.lang diff --git a/fe/src/i18n/i18n.js b/fe/src/i18n/i18n.js index 72f4c17..c2e72af 100644 --- a/fe/src/i18n/i18n.js +++ b/fe/src/i18n/i18n.js @@ -37,6 +37,8 @@ var lang = { "setDNS": "Set DNS", "setSSL": "Set SSL", "setDatabase": "Set Database", + "setAdminPassword": "Set Password", + "admin_account": "Administrator Account", "setOther": "Other", "welcome": "Welcome", "next": "Next", @@ -52,7 +54,10 @@ var lang = { "smtp_domain": "SMTP Domain", "web_domain": "Web Domain", "dns_desc": "Please add the following information to your DNS records", - + "ssl_auto": "Automatically configure SSL certificates (recommended)", + "ssl_manuallyf": "Manually configure an SSL certificate", + "ssl_key_path": "ssl key file path", + "ssl_crt_path": "ssl crt file path", }; @@ -96,6 +101,8 @@ var zhCN = { "setDNS": "DNS设置", "setSSL": "SSL设置", "setDatabase": "数据库设置", + "setAdminPassword": "密码设置", + "admin_account": "管理员账号", "setOther": "其他设置", "welcome": "欢迎", "next": "下一步", @@ -110,7 +117,11 @@ var zhCN = { "domain_desc": "设置你的域名信息。", "smtp_domain": "SMTP域名地址", "web_domain": "Web域名地址", - "dns_desc": "请将以下信息添加到DNS记录中" + "dns_desc": "请将以下信息添加到DNS记录中", + "ssl_auto": "自动配置SSL证书(推荐)", + "ssl_manuallyf": "手动配置SSL证书", + "ssl_key_path": "ssl key文件位置", + "ssl_crt_path": "ssl crt文件位置", } switch (navigator.language) { diff --git a/fe/src/views/EditerView.vue b/fe/src/views/EditerView.vue index 43404e5..c043f88 100644 --- a/fe/src/views/EditerView.vue +++ b/fe/src/views/EditerView.vue @@ -135,7 +135,7 @@ const validateSender = function (rule, value, callback) { } const checkEmail = function (str) { - var re = /^(\w-*\.*)+@(\w-?)+(\.\w{2,})+$/ + var re = /.+@.+\..+/ if (re.test(str)) { return true } else { diff --git a/fe/src/views/SetupView.vue b/fe/src/views/SetupView.vue index 9ae409f..e7a738f 100644 --- a/fe/src/views/SetupView.vue +++ b/fe/src/views/SetupView.vue @@ -3,10 +3,10 @@ + - @@ -48,6 +48,33 @@
+
+

{{ lang.setAdminPassword }}

+ +
+
+ + + + + + + + + + + + + + +
+
+ + +

{{ lang.SetDomail }}

@@ -69,7 +96,7 @@
-
+

{{ lang.setDNS }}

{{ lang.dns_desc }}
@@ -94,8 +121,34 @@
+
+
+

{{ lang.setSSL }}

+
{{ lang.setSSL }}
+
+
+ + + + + + + - {{ lang.next }} + + + + + + + + + +
+
+ + {{ + lang.next }}
@@ -108,10 +161,16 @@ import { ElMessage } from 'element-plus' import router from "@/router"; //根路由对象 import lang from '../i18n/i18n'; +const adminSettings = reactive({ + "account": "admin", + "password": "", + "password2": "", + "hadSeted": false +}) const dbSettings = reactive({ - "type": "", - "dsn": "", + "type": "sqlite", + "dsn": "./pmail.db", "lable": "" }) @@ -120,12 +179,57 @@ const domainSettings = reactive({ "smtp_domain": "" }) +const sslSettings = reactive({ + "type": "0", + "key_path": "./config/ssl/private.key", + "crt_path": "./config/ssl/public.crt" +}) + const active = ref(0) +const fullscreenLoading = ref(false) + const dnsInfos = ref([ - { "host": "smtp", "type": "A", "value": "YouServerIp", "prid": "NA", "ttl": "3600" } ]) +const setPassword = () => { + if (adminSettings.hadSeted) { + active.value++; + getDomainConfig(); + return; + } + + if (adminSettings.password != adminSettings.password2) { + ElMessage.error(lang.err_pwd_diff) + } else { + $http.post("/api/setup", { "action": "set", "step": "password", "account": adminSettings.account, "password": adminSettings.password }).then((res) => { + if (res.errorNo != 0) { + ElMessage.error(res.errorMsg) + } else { + active.value++; + getDomainConfig(); + } + }) + } +} + +const getPassword = () => { + $http.post("/api/setup", { "action": "get", "step": "password" }).then((res) => { + if (res.errorNo != 0) { + ElMessage.error(res.errorMsg) + } else { + adminSettings.hadSeted = res.data != "" + if (adminSettings.hadSeted) { + adminSettings.account = res.data + adminSettings.password = "*******" + adminSettings.password2 = "*******" + } + + } + }) +} + + const getDbConfig = () => { $http.post("/api/setup", { "action": "get", "step": "database" }).then((res) => { if (res.errorNo != 0) { @@ -154,7 +258,7 @@ const setDbConfig = () => { ElMessage.error(res.errorMsg) } else { active.value++; - getDomainConfig(); + getPassword(); } }) } @@ -169,6 +273,34 @@ const getDNSConfig = () => { }) } + +const getSSLConfig = () => { + $http.post("/api/setup", { "action": "get", "step": "ssl" }).then((res) => { + if (res.errorNo != 0) { + ElMessage.error(res.errorMsg) + } else { + sslSettings.type = res.data + } + }) +} + + +const setSSLConfig = () => { + fullscreenLoading.value = true; + $http.post("/api/setup", { "action": "set", "step": "ssl", "ssl_type": sslSettings.type, "key_path": sslSettings.key_path, "crt_path": sslSettings.crt_path }).then((res) => { + if (res.errorNo != 0) { + fullscreenLoading.value = false; + ElMessage.error(res.errorMsg) + } else { + setTimeout(function () { + window.location.href = "https://" + domainSettings.web_domain; + }, 10000); + + } + }) +} + + const setDomainConfig = () => { $http.post("/api/setup", { "action": "set", "step": "domain", "web_domain": domainSettings.web_domain, "smtp_domain": domainSettings.smtp_domain }).then((res) => { if (res.errorNo != 0) { @@ -191,9 +323,17 @@ const next = () => { setDbConfig(); break; case 2: - setDomainConfig(); + setPassword(); break; case 3: + setDomainConfig(); + break; + case 4: + getSSLConfig(); + active.value++ + break + case 5: + setSSLConfig(); active.value++ break } diff --git a/server/config/config.dev.json b/server/config/config.dev.json index f4b3891..da53b9a 100644 --- a/server/config/config.dev.json +++ b/server/config/config.dev.json @@ -1,11 +1,13 @@ { - "domain": "", - "webDomain": "", + "logLevel": "debug", + "domain": "domain.com", + "webDomain": "mail.domain.com", "dkimPrivateKeyPath": "config/dkim/dkim.priv", + "sslType": "0", "SSLPrivateKeyPath": "config/ssl/private.key", "SSLPublicKeyPath": "config/ssl/public.crt", - "dbDSN": "", - "dbType": "", + "dbDSN": "./pmail.db", + "dbType": "sqlite", "weChatPushAppId": "", "weChatPushSecret": "", "weChatPushTemplateId": "", diff --git a/server/config/config.go b/server/config/config.go index a9b405b..2f496b9 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -11,9 +11,11 @@ import ( var IsInit bool type Config struct { + LogLevel string `json:"logLevel"` Domain string `json:"domain"` WebDomain string `json:"webDomain"` DkimPrivateKeyPath string `json:"dkimPrivateKeyPath"` + SSLType string `json:"sslType"` // 0表示自动生成证书,1表示用户上传证书 SSLPrivateKeyPath string `json:"SSLPrivateKeyPath"` SSLPublicKeyPath string `json:"SSLPublicKeyPath"` DbDSN string `json:"dbDSN"` @@ -30,10 +32,12 @@ type Config struct { //go:embed tables/* var tableConfig embed.FS -const Version = "1.1.0" +const Version = "2.0.0" const DBTypeMySQL = "mysql" const DBTypeSQLite = "sqlite" +const SSLTypeAuto = "0" //自动生成证书 +const SSLTypeUser = "1" //用户上传证书 var DBTypes []string = []string{DBTypeMySQL, DBTypeSQLite} diff --git a/server/config/config.json b/server/config/config.json index f4b3891..540a6ef 100644 --- a/server/config/config.json +++ b/server/config/config.json @@ -1,11 +1,13 @@ { - "domain": "", - "webDomain": "", + "logLevel": "info", + "domain": "domain.com", + "webDomain": "mail.domain.com", "dkimPrivateKeyPath": "config/dkim/dkim.priv", + "sslType": "0", "SSLPrivateKeyPath": "config/ssl/private.key", "SSLPublicKeyPath": "config/ssl/public.crt", - "dbDSN": "", - "dbType": "", + "dbDSN": "./pmail.db", + "dbType": "sqlite", "weChatPushAppId": "", "weChatPushSecret": "", "weChatPushTemplateId": "", diff --git a/server/config/dkim/README b/server/config/dkim/README deleted file mode 100644 index 4578ca7..0000000 --- a/server/config/dkim/README +++ /dev/null @@ -1 +0,0 @@ -使用[go-msgauth](https://github.com/emersion/go-msgauth)项目的dkim-keygen工具生成公钥和私钥 \ No newline at end of file diff --git a/server/config/tables/mysql/data/user.sql b/server/config/tables/mysql/data/user.sql index b81caae..e69de29 100644 --- a/server/config/tables/mysql/data/user.sql +++ b/server/config/tables/mysql/data/user.sql @@ -1,2 +0,0 @@ -INSERT INTO user (account, name, password) VALUES ('admin', 'admin', 'faddb6ec2efe16116a342f5512583c48'); - diff --git a/server/config/tables/mysql/data/user_auth.sql b/server/config/tables/mysql/data/user_auth.sql index e44084d..e69de29 100644 --- a/server/config/tables/mysql/data/user_auth.sql +++ b/server/config/tables/mysql/data/user_auth.sql @@ -1,2 +0,0 @@ -INSERT INTO pmail.user_auth (user_id, email_account) VALUES (1, '*'); - diff --git a/server/config/tables/sqlite/data/user.sql b/server/config/tables/sqlite/data/user.sql index b81caae..e69de29 100644 --- a/server/config/tables/sqlite/data/user.sql +++ b/server/config/tables/sqlite/data/user.sql @@ -1,2 +0,0 @@ -INSERT INTO user (account, name, password) VALUES ('admin', 'admin', 'faddb6ec2efe16116a342f5512583c48'); - diff --git a/server/config/tables/sqlite/data/user_auth.sql b/server/config/tables/sqlite/data/user_auth.sql index 7b5228e..e69de29 100644 --- a/server/config/tables/sqlite/data/user_auth.sql +++ b/server/config/tables/sqlite/data/user_auth.sql @@ -1,2 +0,0 @@ -INSERT INTO user_auth (user_id, email_account) VALUES (1, '*'); - diff --git a/server/controllers/interceptor.go b/server/controllers/interceptor.go new file mode 100644 index 0000000..ad67bd1 --- /dev/null +++ b/server/controllers/interceptor.go @@ -0,0 +1,11 @@ +package controllers + +import ( + "net/http" + "pmail/config" +) + +func Interceptor(w http.ResponseWriter, r *http.Request) { + URL := "https://" + config.Instance.WebDomain + r.URL.Path + http.Redirect(w, r, URL, http.StatusMovedPermanently) +} diff --git a/server/controllers/login.go b/server/controllers/login.go index 6c5a5a7..e908363 100644 --- a/server/controllers/login.go +++ b/server/controllers/login.go @@ -1,9 +1,7 @@ package controllers import ( - "crypto/md5" "database/sql" - "encoding/hex" "encoding/json" log "github.com/sirupsen/logrus" "io" @@ -14,6 +12,7 @@ import ( "pmail/i18n" "pmail/models" "pmail/session" + "pmail/utils/password" ) type loginRequest struct { @@ -35,7 +34,7 @@ func Login(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { var user models.User - encodePwd := md5Encode(md5Encode(reqData.Password+"pmail") + "pmail2023") + encodePwd := password.Encode(reqData.Password) err = db.Instance.Get(&user, db.WithContext(ctx, "select * from user where account =? and password =?"), reqData.Account, encodePwd) @@ -51,9 +50,3 @@ func Login(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w) } } - -func md5Encode(str string) string { - h := md5.New() - h.Write([]byte(str)) - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/server/controllers/settings.go b/server/controllers/settings.go index 47297c8..79ca661 100644 --- a/server/controllers/settings.go +++ b/server/controllers/settings.go @@ -9,6 +9,7 @@ import ( "pmail/dto" "pmail/dto/response" "pmail/i18n" + "pmail/utils/password" ) type modifyPasswordRequest struct { @@ -27,7 +28,7 @@ func ModifyPassword(ctx *dto.Context, w http.ResponseWriter, req *http.Request) } if retData.Password != "" { - encodePwd := md5Encode(md5Encode(retData.Password+"pmail") + "pmail2023") + encodePwd := password.Encode(retData.Password) _, err := db.Instance.Exec(db.WithContext(ctx, "update user set password = ? where id =?"), encodePwd, ctx.UserInfo.ID) if err != nil { diff --git a/server/controllers/setup.go b/server/controllers/setup.go index 6eaebd8..833cfed 100644 --- a/server/controllers/setup.go +++ b/server/controllers/setup.go @@ -4,13 +4,23 @@ import ( "encoding/json" "io" "net/http" + "pmail/config" "pmail/dto" "pmail/dto/response" "pmail/services/setup" + "pmail/services/setup/ssl" + "strings" ) -func Proxy(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("proxy")) +func AcmeChallenge(w http.ResponseWriter, r *http.Request) { + instance := ssl.GetHttpChallengeInstance() + token := strings.ReplaceAll(r.URL.Path, "/.well-known/acme-challenge/", "") + auth, exist := instance.AuthInfo[token] + if exist { + w.Write([]byte(auth.KeyAuth)) + } else { + http.NotFound(w, r) + } } func Setup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { @@ -29,9 +39,10 @@ func Setup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { } if reqData["step"] == "database" && reqData["action"] == "get" { - dbType, dbDSN, err := setup.GetDatabaseSettings() + dbType, dbDSN, err := setup.GetDatabaseSettings(ctx) if err != nil { - response.NewErrorResponse(response.ServerError, err.Error(), "") + response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) + return } response.NewSuccessResponse(map[string]string{ @@ -42,19 +53,41 @@ func Setup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { } if reqData["step"] == "database" && reqData["action"] == "set" { - err := setup.SetDatabaseSettings(reqData["db_type"], reqData["db_dsn"]) + err := setup.SetDatabaseSettings(ctx, reqData["db_type"], reqData["db_dsn"]) if err != nil { - response.NewErrorResponse(response.ServerError, err.Error(), "") + response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) + return } response.NewSuccessResponse("Succ").FPrint(w) return } + if reqData["step"] == "password" && reqData["action"] == "get" { + ok, err := setup.GetAdminPassword(ctx) + if err != nil { + response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) + return + } + response.NewSuccessResponse(ok).FPrint(w) + return + } + + if reqData["step"] == "password" && reqData["action"] == "set" { + err := setup.SetAdminPassword(ctx, reqData["account"], reqData["password"]) + if err != nil { + response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) + return + } + response.NewSuccessResponse("Succ").FPrint(w) + return + } + if reqData["step"] == "domain" && reqData["action"] == "get" { smtpDomain, webDomain, err := setup.GetDomainSettings() if err != nil { - response.NewErrorResponse(response.ServerError, err.Error(), "") + response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) + return } response.NewSuccessResponse(map[string]string{ "smtp_domain": smtpDomain, @@ -66,7 +99,8 @@ func Setup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { if reqData["step"] == "domain" && reqData["action"] == "set" { err := setup.SetDomainSettings(reqData["smtp_domain"], reqData["web_domain"]) if err != nil { - response.NewErrorResponse(response.ServerError, err.Error(), "") + response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) + return } response.NewSuccessResponse("Succ").FPrint(w) return @@ -75,18 +109,37 @@ func Setup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { if reqData["step"] == "dns" && reqData["action"] == "get" { dnsInfos, err := setup.GetDNSSettings(ctx) if err != nil { - response.NewErrorResponse(response.ServerError, err.Error(), "") + response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) + return } response.NewSuccessResponse(dnsInfos).FPrint(w) return } if reqData["step"] == "ssl" && reqData["action"] == "get" { - err := setup.GenSSL() - if err != nil { - response.NewErrorResponse(response.ServerError, err.Error(), "") - } - response.NewSuccessResponse("").FPrint(w) + sslType := ssl.GetSSL() + response.NewSuccessResponse(sslType).FPrint(w) return } + + if reqData["step"] == "ssl" && reqData["action"] == "set" { + err := ssl.SetSSL(reqData["ssl_type"]) + if err != nil { + response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) + return + } + + if reqData["ssl_type"] == config.SSLTypeAuto { + err = ssl.GenSSL(false) + if err != nil { + response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) + return + } + } + + response.NewSuccessResponse("Succ").FPrint(w) + setup.Finish(ctx) + return + } + } diff --git a/server/cron_server/ssl_update.go b/server/cron_server/ssl_update.go new file mode 100644 index 0000000..d6a2868 --- /dev/null +++ b/server/cron_server/ssl_update.go @@ -0,0 +1,34 @@ +package cron_server + +import ( + log "github.com/sirupsen/logrus" + "pmail/config" + "pmail/services/setup/ssl" + "pmail/signal" + "time" +) + +func Start() { + for { + if config.Instance.IsInit { + days, err := ssl.CheckSSLCrtInfo() + if days < 30 || err != nil { + if err != nil { + log.Errorf("SSL Check Error, Update SSL Certificate. Error Info :%+v", err) + } else { + log.Infof("SSL certificate remaining time is only %d days, renew SSL certificate.", days) + } + err = ssl.GenSSL(true) + if err != nil { + log.Errorf("SSL Update Error! %+v", err) + } + // 更新完证书,重启服务 + signal.RestartChan <- true + } else { + log.Debugf("SSL Check.") + } + } + // 每24小时检测一次证书有效期 + time.Sleep(24 * time.Hour) + } +} diff --git a/server/db/init.go b/server/db/init.go index 766cab1..dcb1efc 100644 --- a/server/db/init.go +++ b/server/db/init.go @@ -8,11 +8,12 @@ import ( _ "modernc.org/sqlite" "pmail/config" "pmail/dto" + "pmail/utils/errors" ) var Instance *sqlx.DB -func Init() { +func Init() error { dsn := config.Instance.DbDSN var err error @@ -22,15 +23,16 @@ func Init() { case "sqlite": Instance, err = sqlx.Open("sqlite", dsn) default: - return + return errors.New("Database Type Error!") } if err != nil { - panic(err) + return errors.Wrap(err) } Instance.SetMaxOpenConns(100) Instance.SetMaxIdleConns(10) //showMySQLCharacterSet() checkTable() + return nil } func WithContext(ctx *dto.Context, sql string) string { diff --git a/server/hooks/wechat_push/wechat_push.go b/server/hooks/wechat_push/wechat_push.go index 26c888f..b222ac6 100644 --- a/server/hooks/wechat_push/wechat_push.go +++ b/server/hooks/wechat_push/wechat_push.go @@ -41,6 +41,10 @@ func (w *WeChatPushHook) ReceiveParseBefore(email []byte) { } func (w *WeChatPushHook) ReceiveParseAfter(email *parsemail.Email) { + if w.appId == "" || w.secret == "" || w.pushUser == "" { + return + } + w.sendUserMsg(nil, w.pushUser, string(email.Text)) } @@ -91,18 +95,13 @@ func (w *WeChatPushHook) sendUserMsg(ctx *dto.Context, userId string, content st } func NewWechatPushHook() *WeChatPushHook { - if config.Instance.WeChatPushAppId != "" && - config.Instance.WeChatPushSecret != "" && - config.Instance.WeChatPushTemplateId != "" && - config.Instance.WeChatPushUserId != "" { - ret := &WeChatPushHook{ - appId: config.Instance.WeChatPushAppId, - secret: config.Instance.WeChatPushSecret, - templateId: config.Instance.WeChatPushTemplateId, - pushUser: config.Instance.WeChatPushUserId, - } - return ret + ret := &WeChatPushHook{ + appId: config.Instance.WeChatPushAppId, + secret: config.Instance.WeChatPushSecret, + templateId: config.Instance.WeChatPushTemplateId, + pushUser: config.Instance.WeChatPushUserId, } - return nil + return ret + } diff --git a/server/http_server/http_server.go b/server/http_server/http_server.go new file mode 100644 index 0000000..a83139e --- /dev/null +++ b/server/http_server/http_server.go @@ -0,0 +1,35 @@ +package http_server + +import ( + "fmt" + "net/http" + "pmail/controllers" + "time" +) + +const HttpPort = 80 + +// 这个服务是为了拦截http请求转发到https +var httpServer *http.Server + +func HttpStop() { + if httpServer != nil { + httpServer.Close() + } +} + +func HttpStart() { + mux := http.NewServeMux() + mux.HandleFunc("/", controllers.Interceptor) + httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", HttpPort), + Handler: mux, + ReadTimeout: time.Second * 60, + WriteTimeout: time.Second * 60, + } + + err := httpServer.ListenAndServe() + if err != nil { + panic(err) + } +} diff --git a/server/http_server/main.go b/server/http_server/https_server.go similarity index 68% rename from server/http_server/main.go rename to server/http_server/https_server.go index a644d81..f8130f4 100644 --- a/server/http_server/main.go +++ b/server/http_server/https_server.go @@ -9,6 +9,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cast" "io/fs" + olog "log" "math/rand" "net" "net/http" @@ -26,43 +27,19 @@ import ( //go:embed dist/* var local embed.FS -var ip string +const HttpsPort = 443 -const HttpPort = 80 +var httpsServer *http.Server -var setupServer *http.Server - -func SetupStart() { - mux := http.NewServeMux() - fe, err := fs.Sub(local, "dist") - if err != nil { - panic(err) - } - mux.Handle("/", http.FileServer(http.FS(fe))) - mux.HandleFunc("/api/", contextIterceptor(controllers.Setup)) - mux.HandleFunc("/", controllers.Proxy) - - setupServer := &http.Server{ - Addr: fmt.Sprintf(":%d", HttpPort), - Handler: mux, - ReadTimeout: time.Second * 60, - WriteTimeout: time.Second * 60, - } - err = setupServer.ListenAndServe() - if err != nil { - panic(err) - } +type nullWrite struct { } -func SetupStop() { - err := setupServer.Close() - if err != nil { - panic(err) - } +func (w *nullWrite) Write(p []byte) (int, error) { + return len(p), nil } -func Start() { - log.Infof("Http Server Start at :%d", HttpPort) +func HttpsStart() { + log.Infof("Http Server Start") mux := http.NewServeMux() @@ -82,36 +59,27 @@ func Start() { mux.HandleFunc("/attachments/", contextIterceptor(controllers.GetAttachments)) mux.HandleFunc("/attachments/download/", contextIterceptor(controllers.Download)) - server := &http.Server{ - Addr: fmt.Sprintf(":%d", HttpPort), + // go http server会打一堆没用的日志,写一个空的日志处理器,屏蔽掉日志输出 + nullLog := olog.New(&nullWrite{}, "", olog.Ldate) + + httpsServer = &http.Server{ + Addr: fmt.Sprintf(":%d", HttpsPort), Handler: session.Instance.LoadAndSave(mux), ReadTimeout: time.Second * 60, WriteTimeout: time.Second * 60, + ErrorLog: nullLog, } - //err := server.ListenAndServeTLS( "config/ssl/public.crt", "config/ssl/private.key", nil) - err = server.ListenAndServe() + err = httpsServer.ListenAndServeTLS("config/ssl/public.crt", "config/ssl/private.key") if err != nil { panic(err) } } -func getLocalIP() string { - ip := "127.0.0.1" - addrs, err := net.InterfaceAddrs() - if err != nil { - return ip +func HttpsStop() { + if httpsServer != nil { + httpsServer.Close() } - for _, a := range addrs { - if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { - if ipnet.IP.To4() != nil { - ip = ipnet.IP.String() - break - } - } - } - - return ip } func genLogID() string { @@ -158,7 +126,7 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc { } if ctx.UserInfo == nil || ctx.UserInfo.ID == 0 { if r.URL.Path != "/api/ping" && r.URL.Path != "/api/login" { - response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "login_exp"), "").FPrint(w) + response.NewErrorResponse(response.NeedLogin, i18n.GetText(ctx.Lang, "login_exp"), "").FPrint(w) return } } diff --git a/server/http_server/setup_server.go b/server/http_server/setup_server.go new file mode 100644 index 0000000..f3ae900 --- /dev/null +++ b/server/http_server/setup_server.go @@ -0,0 +1,63 @@ +package http_server + +import ( + "fmt" + "io/fs" + "net" + "net/http" + "pmail/controllers" + "time" +) + +var ip string + +// 项目初始化引导用的服务,初始化引导结束后即退出 +var setupServer *http.Server + +func SetupStart() { + mux := http.NewServeMux() + fe, err := fs.Sub(local, "dist") + if err != nil { + panic(err) + } + mux.Handle("/", http.FileServer(http.FS(fe))) + mux.HandleFunc("/api/", contextIterceptor(controllers.Setup)) + // 挑战请求类似这样 /.well-known/acme-challenge/QPyMAyaWw9s5JvV1oruyqWHG7OqkHMJEHPoUz2046KM + mux.HandleFunc("/.well-known/", controllers.AcmeChallenge) + + setupServer = &http.Server{ + Addr: fmt.Sprintf(":%d", HttpPort), + Handler: mux, + ReadTimeout: time.Second * 60, + WriteTimeout: time.Second * 60, + } + err = setupServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + panic(err) + } +} + +func SetupStop() { + err := setupServer.Close() + if err != nil { + panic(err) + } +} + +func getLocalIP() string { + ip := "127.0.0.1" + addrs, err := net.InterfaceAddrs() + if err != nil { + return ip + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + ip = ipnet.IP.String() + break + } + } + } + + return ip +} diff --git a/server/main.go b/server/main.go index b874865..81615c1 100644 --- a/server/main.go +++ b/server/main.go @@ -6,6 +6,7 @@ import ( log "github.com/sirupsen/logrus" "os" "pmail/config" + "pmail/cron_server" "pmail/dto" "pmail/res_init" "time" @@ -47,12 +48,25 @@ func main() { // 日志消息输出可以是任意的io.writer类型 log.SetOutput(os.Stdout) - // 设置日志级别为warn以上 - log.SetLevel(log.DebugLevel) var cst, _ = time.LoadLocation("Asia/Shanghai") time.Local = cst - res_init.Init() + config.Init() + + switch config.Instance.LogLevel { + case "": + log.SetLevel(log.InfoLevel) + case "debug": + log.SetLevel(log.DebugLevel) + case "info": + log.SetLevel(log.InfoLevel) + case "warn": + log.SetLevel(log.WarnLevel) + case "error": + log.SetLevel(log.ErrorLevel) + default: + log.SetLevel(log.InfoLevel) + } log.Infoln("***************************************************") log.Infof("***\tServer Start Success Version:%s\n", config.Version) @@ -61,6 +75,12 @@ func main() { log.Infof("***\tBuild GoLang Version: %s ", goVersion) log.Infoln("***************************************************") + // 定时任务启动 + go cron_server.Start() + + // 核心服务启动 + res_init.Init() + s := make(chan bool) <-s } diff --git a/server/res_init/init.go b/server/res_init/init.go index 7dd12d0..0043ae2 100644 --- a/server/res_init/init.go +++ b/server/res_init/init.go @@ -1,6 +1,7 @@ package res_init import ( + log "github.com/sirupsen/logrus" "os" "pmail/config" "pmail/db" @@ -8,26 +9,43 @@ import ( "pmail/hooks" "pmail/http_server" "pmail/session" + "pmail/signal" "pmail/smtp_server" "pmail/utils/file" ) func Init() { - config.Init() - if config.IsInit { + if !config.IsInit { + dirInit() + + log.Infof("Please click http://127.0.0.1 to continue.\n") + go http_server.SetupStart() + <-signal.InitChan + http_server.SetupStop() + } + + for { + config.Init() parsemail.Init() - db.Init() + err := db.Init() + if err != nil { + panic(err) + } session.Init() hooks.Init() // smtp server start go smtp_server.Start() // http server start - go http_server.Start() - } else { - dirInit() - go http_server.SetupStart() + go http_server.HttpsStart() + go http_server.HttpStart() + <-signal.RestartChan + log.Infof("Server Restart!") + smtp_server.Stop() + http_server.HttpsStop() + http_server.HttpStop() } + } func dirInit() { diff --git a/server/services/auth/auth.go b/server/services/auth/auth.go index cad1076..a6d92b7 100644 --- a/server/services/auth/auth.go +++ b/server/services/auth/auth.go @@ -51,7 +51,7 @@ func DkimGen() string { err error ) - privKey, err = rsa.GenerateKey(rand.Reader, 3072) + privKey, err = rsa.GenerateKey(rand.Reader, 1024) if err != nil { log.Fatalf("Failed to generate key: %v", err) diff --git a/server/services/auth/auth_test.go b/server/services/auth/auth_test.go new file mode 100644 index 0000000..eff72ce --- /dev/null +++ b/server/services/auth/auth_test.go @@ -0,0 +1,7 @@ +package auth + +import "testing" + +func TestDkimGen(t *testing.T) { + DkimGen() +} diff --git a/server/services/setup/db.go b/server/services/setup/db.go index dfd3541..68e189a 100644 --- a/server/services/setup/db.go +++ b/server/services/setup/db.go @@ -4,22 +4,62 @@ import ( "encoding/json" "os" "pmail/config" + "pmail/db" + "pmail/dto" + "pmail/models" "pmail/utils/array" "pmail/utils/errors" "pmail/utils/file" + "pmail/utils/password" ) -func GetDatabaseSettings() (string, string, error) { - configData, err := readConfig() +func GetDatabaseSettings(ctx *dto.Context) (string, string, error) { + configData, err := ReadConfig() if err != nil { return "", "", errors.Wrap(err) } + if configData.DbType == "" && configData.DbDSN == "" { + return config.DBTypeSQLite, "./pmail.db", nil + } + return configData.DbType, configData.DbDSN, nil } -func SetDatabaseSettings(dbType, dbDSN string) error { - configData, err := readConfig() +func GetAdminPassword(ctx *dto.Context) (string, error) { + + users := []*models.User{} + err := db.Instance.Select(&users, "select * from user") + if err != nil { + return "", errors.Wrap(err) + } + + if len(users) > 0 { + return users[0].Account, nil + } + + return "", nil +} + +func SetAdminPassword(ctx *dto.Context, account, pwd string) error { + encodePwd := password.Encode(pwd) + res, err := db.Instance.Exec(db.WithContext(ctx, "INSERT INTO user (account, name, password) VALUES (?, 'admin',?)"), account, encodePwd) + if err != nil { + return errors.Wrap(err) + } + id, err := res.LastInsertId() + if err != nil { + return errors.Wrap(err) + } + _, err = db.Instance.Exec(db.WithContext(ctx, "INSERT INTO user_auth (user_id, email_account) VALUES (?, '*')"), id) + if err != nil { + return errors.Wrap(err) + } + return nil +} + +func SetDatabaseSettings(ctx *dto.Context, dbType, dbDSN string) error { + configData, err := ReadConfig() if err != nil { return errors.Wrap(err) } @@ -31,16 +71,20 @@ func SetDatabaseSettings(dbType, dbDSN string) error { configData.DbType = dbType configData.DbDSN = dbDSN - // 检查数据库是否能正确连接 todo - - err = writeConfig(configData) + err = WriteConfig(configData) + if err != nil { + return errors.Wrap(err) + } + config.Init() + // 检查数据库是否能正确连接 + err = db.Init() if err != nil { return errors.Wrap(err) } return nil } -func writeConfig(cfg *config.Config) error { +func WriteConfig(cfg *config.Config) error { bytes, _ := json.Marshal(cfg) err := os.WriteFile("./config/config.json", bytes, 0666) if err != nil { @@ -49,7 +93,7 @@ func writeConfig(cfg *config.Config) error { return nil } -func readConfig() (*config.Config, error) { +func ReadConfig() (*config.Config, error) { configData := config.Config{ DkimPrivateKeyPath: "config/dkim/dkim.priv", SSLPrivateKeyPath: "config/ssl/private.key", diff --git a/server/services/setup/dns.go b/server/services/setup/dns.go index 8fbbbcf..c1f0d60 100644 --- a/server/services/setup/dns.go +++ b/server/services/setup/dns.go @@ -20,7 +20,7 @@ type DNSItem struct { } func GetDNSSettings(ctx *dto.Context) ([]*DNSItem, error) { - configData, err := readConfig() + configData, err := ReadConfig() if err != nil { return nil, errors.Wrap(err) } diff --git a/server/services/setup/domain.go b/server/services/setup/domain.go index 6d2bafc..9d41fa3 100644 --- a/server/services/setup/domain.go +++ b/server/services/setup/domain.go @@ -5,7 +5,7 @@ import ( ) func GetDomainSettings() (string, string, error) { - configData, err := readConfig() + configData, err := ReadConfig() if err != nil { return "", "", errors.Wrap(err) } @@ -14,17 +14,25 @@ func GetDomainSettings() (string, string, error) { } func SetDomainSettings(smtpDomain, webDomain string) error { - configData, err := readConfig() + configData, err := ReadConfig() if err != nil { return errors.Wrap(err) } + if smtpDomain == "" { + return errors.New("domain must not empty!") + } + + if webDomain == "" { + return errors.New("web domain must not empty!") + } + configData.Domain = smtpDomain configData.WebDomain = webDomain // 检查域名是否指向本机 todo - err = writeConfig(configData) + err = WriteConfig(configData) if err != nil { return errors.Wrap(err) } diff --git a/server/services/setup/finish.go b/server/services/setup/finish.go new file mode 100644 index 0000000..aecb2bb --- /dev/null +++ b/server/services/setup/finish.go @@ -0,0 +1,24 @@ +package setup + +import ( + "pmail/dto" + "pmail/signal" + "pmail/utils/errors" +) + +// Finish 标记初始化完成 +func Finish(ctx *dto.Context) error { + cfg, err := ReadConfig() + if err != nil { + return errors.Wrap(err) + } + cfg.IsInit = true + + err = WriteConfig(cfg) + if err != nil { + return errors.Wrap(err) + } + // 初始化完成 + signal.InitChan <- true + return nil +} diff --git a/server/services/setup/ssl.go b/server/services/setup/ssl.go deleted file mode 100644 index 546dcde..0000000 --- a/server/services/setup/ssl.go +++ /dev/null @@ -1,103 +0,0 @@ -package setup - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "fmt" - log "github.com/sirupsen/logrus" - "pmail/utils/errors" - - "github.com/go-acme/lego/v4/certcrypto" - "github.com/go-acme/lego/v4/certificate" - "github.com/go-acme/lego/v4/challenge/http01" - "github.com/go-acme/lego/v4/challenge/tlsalpn01" - "github.com/go-acme/lego/v4/lego" - "github.com/go-acme/lego/v4/registration" -) - -type MyUser struct { - Email string - Registration *registration.Resource - key crypto.PrivateKey -} - -func (u *MyUser) GetEmail() string { - return u.Email -} -func (u MyUser) GetRegistration() *registration.Resource { - return u.Registration -} -func (u *MyUser) GetPrivateKey() crypto.PrivateKey { - return u.key -} - -func GenSSL() error { - - configData, err := readConfig() - if err != nil { - return errors.Wrap(err) - } - - // Create a user. New accounts need an email and private key to start. - privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - log.Fatal(err) - } - - myUser := MyUser{ - Email: "i@" + configData.Domain, - key: privateKey, - } - - config := lego.NewConfig(&myUser) - - config.Certificate.KeyType = certcrypto.RSA2048 - - // A client facilitates communication with the CA server. - client, err := lego.NewClient(config) - if err != nil { - log.Fatal(err) - } - - // We specify an HTTP port of 5002 and an TLS port of 5001 on all interfaces - // because we aren't running as root and can't bind a listener to port 80 and 443 - // (used later when we attempt to pass challenges). Keep in mind that you still - // need to proxy challenge traffic to port 5002 and 5001. - err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5001")) - if err != nil { - log.Fatal(err) - } - err = client.Challenge.SetTLSALPN01Provider(tlsalpn01.NewProviderServer("", "443")) - if err != nil { - log.Fatal(err) - } - - // New users will need to register - reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) - if err != nil { - log.Fatal(err) - } - myUser.Registration = reg - - request := certificate.ObtainRequest{ - Domains: []string{ - fmt.Sprintf("smtp.%s", configData.Domain), - configData.WebDomain, - }, - Bundle: true, - } - certificates, err := client.Certificate.Obtain(request) - if err != nil { - log.Fatal(err) - } - - // Each certificate comes back with the cert bytes, the bytes of the client's - // private key, and a certificate URL. SAVE THESE TO DISK. - fmt.Printf("%#v\n", certificates) - - // ... all done. - - return nil -} diff --git a/server/services/setup/ssl/challenge.go b/server/services/setup/ssl/challenge.go new file mode 100644 index 0000000..f7aefac --- /dev/null +++ b/server/services/setup/ssl/challenge.go @@ -0,0 +1,37 @@ +package ssl + +type authInfo struct { + Domain string + Token string + KeyAuth string +} + +type HttpChallenge struct { + AuthInfo map[string]*authInfo +} + +var instance *HttpChallenge + +func (h *HttpChallenge) Present(domain, token, keyAuth string) error { + h.AuthInfo[token] = &authInfo{ + Domain: domain, + Token: token, + KeyAuth: keyAuth, + } + + return nil +} + +func (h *HttpChallenge) CleanUp(domain, token, keyAuth string) error { + delete(h.AuthInfo, token) + return nil +} + +func GetHttpChallengeInstance() *HttpChallenge { + if instance == nil { + instance = &HttpChallenge{ + AuthInfo: map[string]*authInfo{}, + } + } + return instance +} diff --git a/server/services/setup/ssl/ssl.go b/server/services/setup/ssl/ssl.go new file mode 100644 index 0000000..b4fef3e --- /dev/null +++ b/server/services/setup/ssl/ssl.go @@ -0,0 +1,172 @@ +package ssl + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "github.com/go-acme/lego/v4/certificate" + "github.com/spf13/cast" + "os" + "pmail/config" + "pmail/services/setup" + "pmail/utils/errors" + "time" + + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/registration" +) + +type MyUser struct { + Email string + Registration *registration.Resource + key crypto.PrivateKey +} + +func (u *MyUser) GetEmail() string { + return u.Email +} +func (u MyUser) GetRegistration() *registration.Resource { + return u.Registration +} +func (u *MyUser) GetPrivateKey() crypto.PrivateKey { + return u.key +} + +func GetSSL() string { + cfg, err := setup.ReadConfig() + if err != nil { + panic(err) + } + if cfg.SSLType == "" { + return config.SSLTypeAuto + } + + return cfg.SSLType +} + +func SetSSL(sslType string) error { + cfg, err := setup.ReadConfig() + if err != nil { + panic(err) + } + if sslType == config.SSLTypeAuto || sslType == config.SSLTypeUser { + cfg.SSLType = sslType + } else { + return errors.New("SSL Type Error!") + } + + err = setup.WriteConfig(cfg) + if err != nil { + return errors.Wrap(err) + } + + return nil +} + +func GenSSL(update bool) error { + + cfg, err := setup.ReadConfig() + if err != nil { + panic(err) + } + + if !update { + privateFile, errpi := os.ReadFile(cfg.SSLPrivateKeyPath) + public, errpu := os.ReadFile(cfg.SSLPublicKeyPath) + // 当前存在证书数据,就不生成了 + if errpi == nil && errpu == nil && len(privateFile) > 0 && len(public) > 0 { + return nil + } + } + + // Create a user. New accounts need an email and private key to start. + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return errors.Wrap(err) + } + + myUser := MyUser{ + Email: "i@" + cfg.Domain, + key: privateKey, + } + + config := lego.NewConfig(&myUser) + + config.Certificate.KeyType = certcrypto.RSA2048 + + // A client facilitates communication with the CA server. + client, err := lego.NewClient(config) + if err != nil { + return errors.Wrap(err) + } + + err = client.Challenge.SetHTTP01Provider(GetHttpChallengeInstance()) + if err != nil { + return errors.Wrap(err) + } + + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + if err != nil { + return errors.Wrap(err) + } + myUser.Registration = reg + + request := certificate.ObtainRequest{ + Domains: []string{"smtp." + cfg.Domain, cfg.WebDomain}, + Bundle: true, + } + certificates, err := client.Certificate.Obtain(request) + if err != nil { + return errors.Wrap(err) + } + + err = os.WriteFile("./config/ssl/private.key", certificates.PrivateKey, 0666) + if err != nil { + return errors.Wrap(err) + } + + err = os.WriteFile("./config/ssl/public.crt", certificates.Certificate, 0666) + if err != nil { + return errors.Wrap(err) + } + + err = os.WriteFile("./config/ssl/issuerCert.crt", certificates.IssuerCertificate, 0666) + if err != nil { + return errors.Wrap(err) + } + + return nil +} + +// CheckSSLCrtInfo 返回证书过期剩余天数 +func CheckSSLCrtInfo() (int, error) { + + cfg, err := setup.ReadConfig() + if err != nil { + panic(err) + } + // load cert and key by tls.LoadX509KeyPair + tlsCert, err := tls.LoadX509KeyPair(cfg.SSLPublicKeyPath, cfg.SSLPrivateKeyPath) + if err != nil { + return -1, errors.Wrap(err) + } + + cert, err := x509.ParseCertificate(tlsCert.Certificate[0]) + + if err != nil { + return -1, errors.Wrap(err) + } + + // 检查过期时间 + hours := cert.NotAfter.Sub(time.Now()).Hours() + + if hours <= 0 { + return -1, errors.New("Certificate has expired") + } + + return cast.ToInt(hours / 24), nil +} diff --git a/server/services/setup/ssl/ssl_test.go b/server/services/setup/ssl/ssl_test.go new file mode 100644 index 0000000..e0213b6 --- /dev/null +++ b/server/services/setup/ssl/ssl_test.go @@ -0,0 +1,17 @@ +package ssl + +import ( + "fmt" + "testing" +) + +func TestGenSSL(t *testing.T) { + err := GenSSL(false) + fmt.Println(err) +} + +func TestGetSSLCrtInfo(t *testing.T) { + days, err := CheckSSLCrtInfo() + + fmt.Println(days, err) +} diff --git a/server/signal/signal.go b/server/signal/signal.go new file mode 100644 index 0000000..dc3887d --- /dev/null +++ b/server/signal/signal.go @@ -0,0 +1,4 @@ +package signal + +var InitChan = make(chan bool) +var RestartChan = make(chan bool) diff --git a/server/smtp_server/main.go b/server/smtp_server/main.go index c692f19..c313709 100644 --- a/server/smtp_server/main.go +++ b/server/smtp_server/main.go @@ -43,19 +43,21 @@ func (s *Session) Logout() error { return nil } +var instance *smtp.Server + func Start() { be := &Backend{} - s := smtp.NewServer(be) + instance = smtp.NewServer(be) - s.Addr = ":25" - s.Domain = config.Instance.Domain - s.ReadTimeout = 10 * time.Second - s.WriteTimeout = 10 * time.Second - s.MaxMessageBytes = 1024 * 1024 - s.MaxRecipients = 50 + instance.Addr = ":25" + instance.Domain = config.Instance.Domain + instance.ReadTimeout = 10 * time.Second + instance.WriteTimeout = 10 * time.Second + instance.MaxMessageBytes = 1024 * 1024 + instance.MaxRecipients = 50 // force TLS for auth - s.AllowInsecureAuth = false + instance.AllowInsecureAuth = false // Load the certificate and key cer, err := tls.LoadX509KeyPair(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath) if err != nil { @@ -63,10 +65,16 @@ func Start() { return } // Configure the TLS support - s.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}} + instance.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}} - log.Println("Starting server at", s.Addr) - if err := s.ListenAndServe(); err != nil { + log.Println("Starting server at", instance.Addr) + if err := instance.ListenAndServe(); err != nil { log.Fatal(err) } } + +func Stop() { + if instance != nil { + instance.Close() + } +} diff --git a/server/utils/password/encode.go b/server/utils/password/encode.go new file mode 100644 index 0000000..3b5b567 --- /dev/null +++ b/server/utils/password/encode.go @@ -0,0 +1,18 @@ +package password + +import ( + "crypto/md5" + "encoding/hex" +) + +// Encode 对密码两次md5加盐 +func Encode(password string) string { + encodePwd := md5Encode(md5Encode(password+"pmail") + "pmail2023") + return encodePwd +} + +func md5Encode(str string) string { + h := md5.New() + h.Write([]byte(str)) + return hex.EncodeToString(h.Sum(nil)) +}