优化:发送通道Webhook支持http/socks5代理

优化:服务端应答`Http 204 No Content`时特殊处理(更新日志状态为成功) #234
This commit is contained in:
pppscn 2024-04-09 15:18:31 +08:00
parent 9107fa4589
commit 5eed98121e
5 changed files with 335 additions and 24 deletions

View File

@ -2,21 +2,36 @@ package com.idormy.sms.forwarder.entity.setting
import com.idormy.sms.forwarder.R
import java.io.Serializable
import java.net.Proxy
data class WebhookSetting(
val method: String? = "POST",
val method: String = "POST",
var webServer: String = "",
val secret: String? = "",
val response: String? = "",
val webParams: String? = "",
val headers: Map<String, String>?,
val secret: String = "",
val response: String = "",
val webParams: String = "",
val headers: Map<String, String> = mapOf(),
val proxyType: Proxy.Type = Proxy.Type.DIRECT,
val proxyHost: String = "",
val proxyPort: String = "",
val proxyAuthenticator: Boolean = false,
val proxyUsername: String = "",
val proxyPassword: String = "",
) : Serializable {
fun getMethodCheckId(): Int {
return when (method) {
null, "POST" -> R.id.rb_method_post
"POST" -> R.id.rb_method_post
"PUT" -> R.id.rb_method_put
"PATCH" -> R.id.rb_method_patch
else -> R.id.rb_method_get
}
}
fun getProxyTypeCheckId(): Int {
return when (proxyType) {
Proxy.Type.HTTP -> R.id.rb_proxyHttp
Proxy.Type.SOCKS -> R.id.rb_proxySocks
else -> R.id.rb_proxyNone
}
}
}

View File

@ -4,9 +4,11 @@ import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CompoundButton
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.RadioGroup
import androidx.fragment.app.viewModels
import com.google.gson.Gson
import com.idormy.sms.forwarder.R
@ -41,11 +43,12 @@ import io.reactivex.SingleObserver
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import java.net.Proxy
import java.util.Date
@Page(name = "Webhook")
@Suppress("PrivatePropertyName")
class WebhookFragment : BaseFragment<FragmentSendersWebhookBinding?>(), View.OnClickListener {
class WebhookFragment : BaseFragment<FragmentSendersWebhookBinding?>(), View.OnClickListener, CompoundButton.OnCheckedChangeListener {
private val TAG: String = WebhookFragment::class.java.simpleName
private var titleBar: TitleBar? = null
@ -132,13 +135,17 @@ class WebhookFragment : BaseFragment<FragmentSendersWebhookBinding?>(), View.OnC
binding!!.etResponse.setText(settingVo.response)
binding!!.etWebParams.setText(settingVo.webParams)
//set header
if (settingVo.headers != null) {
for ((key, value) in settingVo.headers) {
addHeaderItemLinearLayout(
headerItemMap, binding!!.layoutHeaders, key, value
)
}
for ((key, value) in settingVo.headers) {
addHeaderItemLinearLayout(
headerItemMap, binding!!.layoutHeaders, key, value
)
}
binding!!.rgProxyType.check(settingVo.getProxyTypeCheckId())
binding!!.etProxyHost.setText(settingVo.proxyHost)
binding!!.etProxyPort.setText(settingVo.proxyPort)
binding!!.sbProxyAuthenticator.isChecked = settingVo.proxyAuthenticator == true
binding!!.etProxyUsername.setText(settingVo.proxyUsername)
binding!!.etProxyPassword.setText(settingVo.proxyPassword)
}
}
})
@ -151,9 +158,26 @@ class WebhookFragment : BaseFragment<FragmentSendersWebhookBinding?>(), View.OnC
binding!!.btnAddHeader.setOnClickListener {
addHeaderItemLinearLayout(headerItemMap, binding!!.layoutHeaders, null, null)
}
binding!!.sbProxyAuthenticator.setOnCheckedChangeListener(this)
binding!!.rgProxyType.setOnCheckedChangeListener { _: RadioGroup?, checkedId: Int ->
if (checkedId == R.id.rb_proxyHttp || checkedId == R.id.rb_proxySocks) {
binding!!.layoutProxyHost.visibility = View.VISIBLE
binding!!.layoutProxyPort.visibility = View.VISIBLE
binding!!.layoutProxyAuthenticator.visibility = if (binding!!.sbProxyAuthenticator.isChecked) View.VISIBLE else View.GONE
} else {
binding!!.layoutProxyHost.visibility = View.GONE
binding!!.layoutProxyPort.visibility = View.GONE
binding!!.layoutProxyAuthenticator.visibility = View.GONE
}
}
LiveEventBus.get(KEY_SENDER_TEST, String::class.java).observe(this) { mCountDownHelper?.finish() }
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
//注意因为只有一个监听暂不需要判断id
binding!!.layoutProxyAuthenticator.visibility = if (isChecked) View.VISIBLE else View.GONE
}
@SingleClick
override fun onClick(v: View) {
try {
@ -233,7 +257,26 @@ class WebhookFragment : BaseFragment<FragmentSendersWebhookBinding?>(), View.OnC
val webParams = binding!!.etWebParams.text.toString().trim()
val headers = getHeadersFromHeaderItemMap(headerItemMap)
return WebhookSetting(method, webServer, secret, response, webParams, headers)
val proxyType: Proxy.Type = when (binding!!.rgProxyType.checkedRadioButtonId) {
R.id.rb_proxyHttp -> Proxy.Type.HTTP
R.id.rb_proxySocks -> Proxy.Type.SOCKS
else -> Proxy.Type.DIRECT
}
val proxyHost = binding!!.etProxyHost.text.toString().trim()
val proxyPort = binding!!.etProxyPort.text.toString().trim()
if (proxyType != Proxy.Type.DIRECT && (TextUtils.isEmpty(proxyHost) || TextUtils.isEmpty(proxyPort))) {
throw Exception(getString(R.string.invalid_host_or_port))
}
val proxyAuthenticator = binding!!.sbProxyAuthenticator.isChecked
val proxyUsername = binding!!.etProxyUsername.text.toString().trim()
val proxyPassword = binding!!.etProxyPassword.text.toString().trim()
if (proxyAuthenticator && TextUtils.isEmpty(proxyUsername) && TextUtils.isEmpty(proxyPassword)) {
throw Exception(getString(R.string.invalid_username_or_password))
}
return WebhookSetting(method, webServer, secret, response, webParams, headers, proxyType, proxyHost, proxyPort, proxyAuthenticator, proxyUsername, proxyPassword)
}

View File

@ -0,0 +1,51 @@
package com.idormy.sms.forwarder.utils.interceptor
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.google.gson.Gson
import com.idormy.sms.forwarder.entity.result.SendResponse
import com.idormy.sms.forwarder.utils.Log
import com.idormy.sms.forwarder.utils.Worker
import com.idormy.sms.forwarder.workers.UpdateLogsWorker
import com.xuexiang.xutil.XUtil
import okhttp3.Interceptor
import okhttp3.Response
import java.util.concurrent.TimeUnit
class NoContentInterceptor(private val logId: Long) : Interceptor {
private val TAG: String = NoContentInterceptor::class.java.simpleName
override fun intercept(chain: Interceptor.Chain): Response {
val originalResponse = chain.proceed(chain.request())
if (originalResponse.code() == 204) {
val response = "HTTP 204 No Content"
Log.d(TAG, response)
/*
// 创建一个空的响应体
val message = "{\"Code\":0, \"Msg\":\"\", \"Data\":{}}"
val emptyJsonBody = ResponseBody.create(MediaType.parse("application/json"), message)
// 使用新的响应体替换原始响应中的响应体
return originalResponse.newBuilder()
.body(emptyJsonBody)
.header("Content-Length", message.length.toString())
.build()
*/
//TODO: 暂时特殊处理,更新日志状态为成功
val sendResponse = SendResponse(logId, 2, response)
val request = OneTimeWorkRequestBuilder<UpdateLogsWorker>()
.setInitialDelay(200, TimeUnit.MILLISECONDS)
.setInputData(
workDataOf(
Worker.UPDATE_LOGS to Gson().toJson(sendResponse)
)
).build()
WorkManager.getInstance(XUtil.getContext()).enqueue(request)
}
return originalResponse
}
}

View File

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.text.TextUtils
import android.util.Base64
import com.google.gson.Gson
import com.idormy.sms.forwarder.R
import com.idormy.sms.forwarder.database.entity.Rule
import com.idormy.sms.forwarder.entity.MsgInfo
import com.idormy.sms.forwarder.entity.setting.WebhookSetting
@ -11,16 +12,29 @@ import com.idormy.sms.forwarder.utils.AppUtils
import com.idormy.sms.forwarder.utils.Log
import com.idormy.sms.forwarder.utils.SendUtils
import com.idormy.sms.forwarder.utils.SettingUtils
import com.idormy.sms.forwarder.utils.interceptor.BasicAuthInterceptor
import com.idormy.sms.forwarder.utils.interceptor.LoggingInterceptor
import com.idormy.sms.forwarder.utils.interceptor.NoContentInterceptor
import com.xuexiang.xhttp2.XHttp
import com.xuexiang.xhttp2.callback.SimpleCallBack
import com.xuexiang.xhttp2.exception.ApiException
import com.xuexiang.xutil.net.NetworkUtils
import com.xuexiang.xutil.resource.ResUtils
import okhttp3.Credentials
import okhttp3.Response
import okhttp3.Route
import java.net.Authenticator
import java.net.InetSocketAddress
import java.net.PasswordAuthentication
import java.net.Proxy
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.text.SimpleDateFormat
import java.util.*
import java.util.Date
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class WebhookUtils {
companion object {
@ -57,7 +71,7 @@ class WebhookUtils {
val mac = Mac.getInstance("HmacSHA256")
mac.init(
SecretKeySpec(
setting.secret?.toByteArray(StandardCharsets.UTF_8),
setting.secret.toByteArray(StandardCharsets.UTF_8),
"HmacSHA256"
)
)
@ -65,7 +79,7 @@ class WebhookUtils {
sign = URLEncoder.encode(String(Base64.encode(signData, Base64.NO_WRAP)), "UTF-8")
}
var webParams = setting.webParams?.trim()
var webParams = setting.webParams.trim()
//支持HTTP基本认证(Basic Authentication)
val regex = "^(https?://)([^:]+):([^@]+)@(.+)"
@ -90,7 +104,7 @@ class WebhookUtils {
Log.d(TAG, "method = GET, Url = $requestUrl")
XHttp.get(requestUrl).keepJson(true)
} else if (setting.method == "GET" && !TextUtils.isEmpty(webParams)) {
webParams = webParams.toString().replace("[from]", URLEncoder.encode(from, "UTF-8"))
webParams = webParams.replace("[from]", URLEncoder.encode(from, "UTF-8"))
.replace("[content]", URLEncoder.encode(content, "UTF-8"))
.replace("[msg]", URLEncoder.encode(content, "UTF-8"))
.replace("[org_content]", URLEncoder.encode(orgContent, "UTF-8"))
@ -114,7 +128,7 @@ class WebhookUtils {
}
Log.d(TAG, "method = GET, Url = $requestUrl")
XHttp.get(requestUrl).keepJson(true)
} else if (!webParams.isNullOrEmpty() && webParams.startsWith("{")) {
} else if (webParams.isNotEmpty() && webParams.startsWith("{")) {
val bodyMsg = webParams.replace("[from]", from)
.replace("[content]", escapeJson(content))
.replace("[msg]", escapeJson(content))
@ -136,7 +150,7 @@ class WebhookUtils {
else -> XHttp.post(requestUrl).keepJson(true).upJson(bodyMsg)
}
} else {
if (webParams.isNullOrEmpty()) {
if (webParams.isEmpty()) {
webParams = "from=[from]&content=[content]&timestamp=[timestamp]"
if (!TextUtils.isEmpty(sign)) webParams += "&sign=[sign]"
}
@ -171,7 +185,7 @@ class WebhookUtils {
}
//添加headers
for ((key, value) in setting.headers?.entries!!) {
for ((key, value) in setting.headers.entries) {
request.headers(key, value)
}
@ -180,24 +194,65 @@ class WebhookUtils {
request.addInterceptor(BasicAuthInterceptor(matches[2], matches[3]))
}
//设置代理
if ((setting.proxyType == Proxy.Type.HTTP || setting.proxyType == Proxy.Type.SOCKS)
&& !TextUtils.isEmpty(setting.proxyHost) && !TextUtils.isEmpty(setting.proxyPort)
) {
//代理服务器的IP和端口号
Log.d(TAG, "proxyHost = ${setting.proxyHost}, proxyPort = ${setting.proxyPort}")
val proxyHost = if (NetworkUtils.isIP(setting.proxyHost)) setting.proxyHost else NetworkUtils.getDomainAddress(setting.proxyHost)
if (!NetworkUtils.isIP(proxyHost)) {
throw Exception(String.format(ResUtils.getString(R.string.invalid_proxy_host), proxyHost))
}
val proxyPort: Int = setting.proxyPort.toInt()
Log.d(TAG, "proxyHost = $proxyHost, proxyPort = $proxyPort")
request.okproxy(Proxy(setting.proxyType, InetSocketAddress(proxyHost, proxyPort)))
//代理的鉴权账号密码
if (setting.proxyAuthenticator && (!TextUtils.isEmpty(setting.proxyUsername) || !TextUtils.isEmpty(setting.proxyPassword))
) {
Log.i(TAG, "proxyUsername = ${setting.proxyUsername}, proxyPassword = ${setting.proxyPassword}")
if (setting.proxyType == Proxy.Type.HTTP) {
request.okproxyAuthenticator { _: Route?, response: Response ->
//设置代理服务器账号密码
val credential = Credentials.basic(setting.proxyUsername, setting.proxyPassword)
response.request().newBuilder()
.header("Proxy-Authorization", credential)
.build()
}
} else {
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(setting.proxyUsername, setting.proxyPassword.toCharArray())
}
})
}
}
}
request.ignoreHttpsCert() //忽略https证书
.retryCount(SettingUtils.requestRetryTimes) //超时重试的次数
.retryDelay(SettingUtils.requestDelayTime * 1000) //超时重试的延迟时间
.retryIncreaseDelay(SettingUtils.requestDelayTime * 1000) //超时重试叠加延时
.timeStamp(true) //url自动追加时间戳避免缓存
.addInterceptor(LoggingInterceptor(logId)) //增加一个log拦截器, 记录请求日志
.execute(object : SimpleCallBack<String>() {
.addInterceptor(NoContentInterceptor(logId)) //拦截 HTTP 204 响应
.execute(object : SimpleCallBack<Any>() {
override fun onError(e: ApiException) {
e.printStackTrace()
Log.e(TAG, e.detailMessage)
val status = 0
SendUtils.updateLogs(logId, status, e.displayMessage)
SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId)
}
override fun onSuccess(response: String) {
override fun onSuccess(resp: Any) {
val response = resp.toString()
Log.i(TAG, response)
val status = if (!setting.response.isNullOrEmpty() && !response.contains(setting.response)) 0 else 2
val status = if (setting.response.isNotEmpty() && !response.contains(setting.response)) 0 else 2
SendUtils.updateLogs(logId, status, response)
SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId)
}

View File

@ -248,6 +248,153 @@
</LinearLayout>
<LinearLayout
style="@style/BarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/proxy_settings"
android:textStyle="bold" />
<RadioGroup
android:id="@+id/rg_proxyType"
style="@style/rg_style"
android:layout_marginStart="5dp"
android:orientation="horizontal">
<RadioButton
android:id="@+id/rb_proxyNone"
style="@style/rg_rb_style"
android:checked="true"
android:text="@string/proxy_none" />
<RadioButton
android:id="@+id/rb_proxyHttp"
style="@style/rg_rb_style"
android:text="@string/proxy_http" />
<RadioButton
android:id="@+id/rb_proxySocks"
style="@style/rg_rb_style"
android:text="@string/proxy_socks" />
</RadioGroup>
</LinearLayout>
<LinearLayout
android:id="@+id/layoutProxyHost"
style="@style/BarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hostname" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_proxyHost"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:singleLine="true"
app:met_clearButton="true" />
</LinearLayout>
<LinearLayout
android:id="@+id/layoutProxyPort"
style="@style/BarStyle.Switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/port" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_proxyPort"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:digits="0123456789"
android:inputType="number"
android:maxLength="5"
android:singleLine="true"
app:met_clearButton="true" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:text="@string/proxy_authenticator" />
<com.xuexiang.xui.widget.button.switchbutton.SwitchButton
android:id="@+id/sb_proxyAuthenticator"
style="@style/SwitchButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:id="@+id/layoutProxyAuthenticator"
style="@style/BarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/username" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_proxyUsername"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:singleLine="true"
app:met_clearButton="true" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:text="@string/password" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_proxyPassword"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:singleLine="true"
app:met_passWordButton="true" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>