Added more darktheme

- Added more dark theme css
- Merged main branch fixes and new features
- Added todo tag for custom timeout
This commit is contained in:
Toby Chui 2024-11-14 21:18:05 +08:00
parent 4cf5d29692
commit ec5c24b9b8
9 changed files with 276 additions and 82 deletions

View File

@ -308,7 +308,6 @@ func (a *AutoRenewer) CheckAndRenewCertificates() ([]string, error) {
} }
if CertExpireSoon(certBytes, a.EarlyRenewDays) || CertIsExpired(certBytes) { if CertExpireSoon(certBytes, a.EarlyRenewDays) || CertIsExpired(certBytes) {
//This cert is expired //This cert is expired
DNSName, err := ExtractDomains(certBytes) DNSName, err := ExtractDomains(certBytes)
if err != nil { if err != nil {
//Maybe self signed. Ignore this //Maybe self signed. Ignore this

View File

@ -109,6 +109,8 @@ func NewDynamicProxyCore(target *url.URL, prepender string, dpcOptions *DpcoreOp
thisTransporter.(*http.Transport).MaxConnsPerHost = optimalConcurrentConnection * 2 thisTransporter.(*http.Transport).MaxConnsPerHost = optimalConcurrentConnection * 2
thisTransporter.(*http.Transport).DisableCompression = true thisTransporter.(*http.Transport).DisableCompression = true
//TODO: Add user adjustable timeout option here
if dpcOptions.IgnoreTLSVerification { if dpcOptions.IgnoreTLSVerification {
//Ignore TLS certificate validation error //Ignore TLS certificate validation error
thisTransporter.(*http.Transport).TLSClientConfig.InsecureSkipVerify = true thisTransporter.(*http.Transport).TLSClientConfig.InsecureSkipVerify = true

View File

@ -50,21 +50,6 @@ func NewSSHProxyManager() *Manager {
} }
} }
// Get the next free port in the list
func (m *Manager) GetNextPort() int {
nextPort := m.StartingPort
occupiedPort := make(map[int]bool)
for _, instance := range m.Instances {
occupiedPort[instance.AssignedPort] = true
}
for {
if !occupiedPort[nextPort] {
return nextPort
}
nextPort++
}
}
func (m *Manager) HandleHttpByInstanceId(instanceId string, w http.ResponseWriter, r *http.Request) { func (m *Manager) HandleHttpByInstanceId(instanceId string, w http.ResponseWriter, r *http.Request) {
targetInstance, err := m.GetInstanceById(instanceId) targetInstance, err := m.GetInstanceById(instanceId)
if err != nil { if err != nil {
@ -168,6 +153,17 @@ func (i *Instance) CreateNewConnection(listenPort int, username string, remoteIp
if username != "" { if username != "" {
connAddr = username + "@" + remoteIpAddr connAddr = username + "@" + remoteIpAddr
} }
//Trim the space in the username and remote address
username = strings.TrimSpace(username)
remoteIpAddr = strings.TrimSpace(remoteIpAddr)
//Validate the username and remote address
err := ValidateUsernameAndRemoteAddr(username, remoteIpAddr)
if err != nil {
return err
}
configPath := filepath.Join(filepath.Dir(i.ExecPath), ".gotty") configPath := filepath.Join(filepath.Dir(i.ExecPath), ".gotty")
title := username + "@" + remoteIpAddr title := username + "@" + remoteIpAddr
if remotePort != 22 { if remotePort != 22 {

View File

@ -0,0 +1,66 @@
package sshprox
import (
"testing"
)
func TestInstance_Destroy(t *testing.T) {
manager := NewSSHProxyManager()
instance, err := manager.NewSSHProxy("/tmp")
if err != nil {
t.Fatalf("Failed to create new SSH proxy: %v", err)
}
instance.Destroy()
if len(manager.Instances) != 0 {
t.Errorf("Expected Instances to be empty, got %d", len(manager.Instances))
}
}
func TestInstance_ValidateUsernameAndRemoteAddr(t *testing.T) {
tests := []struct {
username string
remoteAddr string
expectError bool
}{
{"validuser", "127.0.0.1", false},
{"valid.user", "example.com", false},
{"; bash ;", "example.com", true},
{"valid-user", "example.com", false},
{"invalid user", "127.0.0.1", true},
{"validuser", "invalid address", true},
{"invalid@user", "127.0.0.1", true},
{"validuser", "invalid@address", true},
{"injection; rm -rf /", "127.0.0.1", true},
{"validuser", "127.0.0.1; rm -rf /", true},
{"$(reboot)", "127.0.0.1", true},
{"validuser", "$(reboot)", true},
{"validuser", "127.0.0.1; $(reboot)", true},
{"validuser", "127.0.0.1 | ls", true},
{"validuser", "127.0.0.1 & ls", true},
{"validuser", "127.0.0.1 && ls", true},
{"validuser", "127.0.0.1 |& ls", true},
{"validuser", "127.0.0.1 ; ls", true},
{"validuser", "2001:0db8:85a3:0000:0000:8a2e:0370:7334", false},
{"validuser", "2001:db8::ff00:42:8329", false},
{"validuser", "2001:db8:0:1234:0:567:8:1", false},
{"validuser", "2001:db8::1234:0:567:8:1", false},
{"validuser", "2001:db8:0:0:0:0:2:1", false},
{"validuser", "2001:db8::2:1", false},
{"validuser", "2001:db8:0:0:8:800:200c:417a", false},
{"validuser", "2001:db8::8:800:200c:417a", false},
{"validuser", "2001:db8:0:0:8:800:200c:417a; rm -rf /", true},
{"validuser", "2001:db8::8:800:200c:417a; rm -rf /", true},
}
for _, test := range tests {
err := ValidateUsernameAndRemoteAddr(test.username, test.remoteAddr)
if test.expectError && err == nil {
t.Errorf("Expected error for username %s and remoteAddr %s, but got none", test.username, test.remoteAddr)
}
if !test.expectError && err != nil {
t.Errorf("Did not expect error for username %s and remoteAddr %s, but got %v", test.username, test.remoteAddr, err)
}
}
}

View File

@ -1,9 +1,11 @@
package sshprox package sshprox
import ( import (
"errors"
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
"regexp"
"runtime" "runtime"
"strings" "strings"
"time" "time"
@ -34,6 +36,21 @@ func IsWebSSHSupported() bool {
return true return true
} }
// Get the next free port in the list
func (m *Manager) GetNextPort() int {
nextPort := m.StartingPort
occupiedPort := make(map[int]bool)
for _, instance := range m.Instances {
occupiedPort[instance.AssignedPort] = true
}
for {
if !occupiedPort[nextPort] {
return nextPort
}
nextPort++
}
}
// Check if a given domain and port is a valid ssh server // Check if a given domain and port is a valid ssh server
func IsSSHConnectable(ipOrDomain string, port int) bool { func IsSSHConnectable(ipOrDomain string, port int) bool {
timeout := time.Second * 3 timeout := time.Second * 3
@ -60,13 +77,47 @@ func IsSSHConnectable(ipOrDomain string, port int) bool {
return string(buf[:7]) == "SSH-2.0" return string(buf[:7]) == "SSH-2.0"
} }
// Check if the port is used by other process or application // Validate the username and remote address to prevent injection
func isPortInUse(port int) bool { func ValidateUsernameAndRemoteAddr(username string, remoteIpAddr string) error {
address := fmt.Sprintf(":%d", port) // Validate and sanitize the username to prevent ssh injection
listener, err := net.Listen("tcp", address) validUsername := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
if err != nil { if !validUsername.MatchString(username) {
return errors.New("invalid username, only alphanumeric characters, dots, underscores and dashes are allowed")
}
//Check if the remoteIpAddr is a valid ipv4 or ipv6 address
if net.ParseIP(remoteIpAddr) != nil {
//A valid IP address do not need further validation
return nil
}
// Validate and sanitize the remote domain to prevent injection
validRemoteAddr := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
if !validRemoteAddr.MatchString(remoteIpAddr) {
return errors.New("invalid remote address, only alphanumeric characters, dots, underscores and dashes are allowed")
}
return nil
}
// Check if the given ip or domain is a loopback address
// or resolves to a loopback address
func IsLoopbackIPOrDomain(ipOrDomain string) bool {
if strings.EqualFold(strings.TrimSpace(ipOrDomain), "localhost") || strings.TrimSpace(ipOrDomain) == "127.0.0.1" {
return true return true
} }
listener.Close()
//Check if the ipOrDomain resolves to a loopback address
ips, err := net.LookupIP(ipOrDomain)
if err != nil {
return false
}
for _, ip := range ips {
if ip.IsLoopback() {
return true
}
}
return false return false
} }

View File

@ -101,7 +101,7 @@
<p style="margin-bottom: 0.4em;"><i class="ui upload icon"></i> Upload Default Keypairs</p> <p style="margin-bottom: 0.4em;"><i class="ui upload icon"></i> Upload Default Keypairs</p>
<div class="ui buttons"> <div class="ui buttons">
<button class="ui basic grey button" onclick="uploadPublicKey();"><i class="globe icon"></i> Public Key</button> <button class="ui basic grey button" onclick="uploadPublicKey();"><i class="globe icon"></i> Public Key</button>
<button class="ui basic black button" onclick="uploadPrivateKey();"><i class="black lock icon"></i> Private Key</button> <button class="ui basic button" onclick="uploadPrivateKey();"><i class="grey lock icon"></i> Private Key</button>
</div> </div>
</div> </div>
<div class="ui divider"></div> <div class="ui divider"></div>

View File

@ -144,11 +144,7 @@ body.darkTheme .ui.toggle.checkbox input:checked ~ label::before{
background-color: var(--theme_bg) !important; background-color: var(--theme_bg) !important;
color: var(--text_color) !important; color: var(--text_color) !important;
border: 1px solid var(--divider_color) !important; border: 1px solid var(--divider_color) !important;
} }s
.toobar #mainmenu a.item:hover{
background-color: var(--theme_highlight) !important;
}
body.darkTheme .ui.segment:not(.basic) { body.darkTheme .ui.segment:not(.basic) {
background-color: var(--theme_bg) !important; background-color: var(--theme_bg) !important;
@ -156,6 +152,12 @@ body.darkTheme .ui.segment:not(.basic) {
border: 1px solid transparent !important; border: 1px solid transparent !important;
} }
body.darkTheme .ui.segment{
background-color: transparent !important;
color: var(--text_color) !important;
border: 1px solid transparent !important;
}
body.darkTheme .sub.header { body.darkTheme .sub.header {
color: var(--text_color) !important; color: var(--text_color) !important;
} }
@ -565,7 +567,7 @@ body.darkTheme .ui.selection.fluid.dropdown#accessRuleSelector .dropdown.icon {
/* Tab Menu in access control */ /* Tab Menu in access control */
body.darkTheme .ui.top.attached.tabular.menu { body.darkTheme .ui.top.attached.tabular.menu {
background-color: var(--theme_bg) !important; background-color: transparent !important;
color: var(--text_color) !important; color: var(--text_color) !important;
} }
@ -579,7 +581,7 @@ body.darkTheme .ui.top.attached.tabular.menu .item:hover {
} }
body.darkTheme .ui.top.attached.tabular.menu .item.active { body.darkTheme .ui.top.attached.tabular.menu .item.active {
background-color: var(--theme_bg) !important; background-color: var(--theme_bg_primary) !important;
color: var(--text_color) !important; color: var(--text_color) !important;
} }
@ -706,3 +708,48 @@ body.darkTheme .ui.selection.dropdown#defaultCA .dropdown.icon {
color: var(--text_color) !important; color: var(--text_color) !important;
} }
/*
ZeroTier
*/
body.darkTheme #gan .ui.list .item .icon {
color: var(--icon_color) !important;
}
body.darkTheme #gan .ui.list .item .content .header {
color: var(--text_color) !important;
}
body.darkTheme #gan .ui.list .item .content .description {
color: var(--text_color_secondary) !important;
}
body.darkTheme #gan .clickable.iprange.active {
background-color: var(--theme_highlight) !important;
}
body.darkTheme #gan thead th {
background-color: var(--theme_bg_secondary) !important;
color: var(--text_color) !important;
border-color: var(--divider_color) !important;
}
/*
Uptime Monitor
*/
body.darkTheme #utm .standardContainer {
background-color: var(--theme_bg) !important;
color: var(--text_color) !important;
border: 1px solid var(--divider_color) !important;
}
body.darkTheme #utm .standardContainer .padding.statusDot {
background-color: var(--theme_bg) !important;
border: 0.2px solid var(--theme_bg_primary) !important;
}

View File

@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<!-- Notes: This should be open in its original path--> <!-- Notes: This should be open in its original path-->
<meta charset="utf-8"> <meta charset="utf-8" />
<link rel="stylesheet" href="../script/semantic/semantic.min.css" /> <link rel="stylesheet" href="../script/semantic/semantic.min.css" />
<script src="../script/jquery-3.6.0.min.js"></script> <script src="../script/jquery-3.6.0.min.js"></script>
<script src="../script/semantic/semantic.min.js"></script> <script src="../script/semantic/semantic.min.js"></script>
@ -10,6 +10,19 @@
<body> <body>
<br /> <br />
<div class="ui container"> <div class="ui container">
<div class="field">
<div class="ui checkbox">
<input type="checkbox" id="showUnexposed" class="hidden" />
<label for="showUnexposed"
>Show Containers with Unexposed Ports
<br />
<small
>Please make sure Zoraxy and the target container share a
network</small
>
</label>
</div>
</div>
<div class="ui header"> <div class="ui header">
<div class="content"> <div class="content">
List of Docker Containers List of Docker Containers
@ -33,56 +46,70 @@
</div> </div>
<script> <script>
const lines = {}; let lines = {};
const linesAded = {}; let linesAdded = {};
document
.getElementById("showUnexposed")
.addEventListener("change", () => {
console.log("showUnexposed", $("#showUnexposed").is(":checked"));
$("#containersList").html('<div class="ui loader active"></div>');
$("#containersAddedList").empty();
$("#containersAddedListHeader").attr("hidden", true);
lines = {};
linesAdded = {};
getDockerContainers();
});
function getDockerContainers() { function getDockerContainers() {
const hostRequest = $.get("/api/proxy/list?type=host"); const hostRequest = $.get("/api/proxy/list?type=host");
const dockerRequest = $.get("/api/docker/containers"); const dockerRequest = $.get("/api/docker/containers");
// Wait for both requests to complete
Promise.all([hostRequest, dockerRequest]) Promise.all([hostRequest, dockerRequest])
.then(([hostData, dockerData]) => { .then(([hostData, dockerData]) => {
if ( if (!dockerData.error && !hostData.error) {
dockerData.error === undefined &&
hostData.error === undefined
) {
const { containers, network } = dockerData; const { containers, network } = dockerData;
const bridge = network.find(({ Name }) => Name === "bridge");
const { const existingTargets = new Set(
IPAM: { hostData.flatMap(({ ActiveOrigins }) =>
Config: [{ Gateway: gateway }], ActiveOrigins.map(({ OriginIpOrDomain }) => OriginIpOrDomain)
}, )
} = bridge; );
const existedDomains = hostData.reduce((acc, { ActiveOrigins }) => {
return acc.concat(ActiveOrigins.map(({ OriginIpOrDomain }) => OriginIpOrDomain));
}, []);
for (const container of containers) { for (const container of containers) {
const { const Ports = container.Ports;
Ports, const name = container.Names[0].replace(/^\//, "");
Names: [name],
} = container;
for (const portObject of Ports.filter( for (const portObject of Ports) {
({ IP: ip }) => ip === "::" || ip === '0.0.0.0' let port = portObject.PublicPort;
)) { if (!port) {
const { IP: ip, PublicPort: port } = portObject; if (!$("#showUnexposed").is(":checked")) {
continue;
}
port = portObject.PrivatePort;
}
const key = `${name}-${port}`; const key = `${name}-${port}`;
// if port is not exposed, use container's name and let docker handle the routing
// BUT this will only work if the container is on the same network as Zoraxy
const targetAddress = portObject.IP || name;
if ( if (
existedDomains.some((item) => item === `${gateway}:${port}`) && existingTargets.has(`${targetAddress}:${port}`) &&
!linesAded[key] !linesAdded[key]
) { ) {
linesAded[key] = { linesAdded[key] = {
name: name.replace(/^\//, ""), name,
ip: gateway, ip: targetAddress,
port, port,
}; };
} else if (!lines[key]) { } else if (!lines[key]) {
lines[key] = { lines[key] = {
name: name.replace(/^\//, ""), name,
ip: gateway, ip: targetAddress,
port, port,
}; };
} }
@ -92,29 +119,31 @@
for (const [key, line] of Object.entries(lines)) { for (const [key, line] of Object.entries(lines)) {
$("#containersList").append( $("#containersList").append(
`<div class="item"> `<div class="item">
<div class="right floated content"> <div class="right floated content">
<div class="ui button" onclick="addContainerItem('${key}');">Add</div> <div class="ui button" onclick="addContainerItem('${key}');">Add</div>
</div> </div>
<div class="content"> <div class="content">
<div class="header">${line.name}</div> <div class="header">${line.name}</div>
<div class="description"> <div class="description">
${line.ip}:${line.port} ${line.ip}:${line.port}
</div> </div>
</div>` </div>`
); );
} }
for (const [key, line] of Object.entries(linesAded)) {
for (const [key, line] of Object.entries(linesAdded)) {
$("#containersAddedList").append( $("#containersAddedList").append(
`<div class="item"> `<div class="item">
<div class="content"> <div class="content">
<div class="header">${line.name}</div> <div class="header">${line.name}</div>
<div class="description"> <div class="description">
${line.ip}:${line.port} ${line.ip}:${line.port}
</div> </div>
</div>` </div>`
); );
} }
Object.entries(linesAded).length &&
Object.entries(linesAdded).length &&
$("#containersAddedListHeader").removeAttr("hidden"); $("#containersAddedListHeader").removeAttr("hidden");
$("#containersList .loader").removeClass("active"); $("#containersList .loader").removeClass("active");
} else { } else {
@ -122,7 +151,11 @@
`Error loading data: ${dockerData.error || hostData.error}`, `Error loading data: ${dockerData.error || hostData.error}`,
false false
); );
$("#containersList").html(`<div class="ui basic segment"><i class="ui red times icon"></i> ${dockerData.error || hostData.error}</div>`); $("#containersList").html(
`<div class="ui basic segment"><i class="ui red times icon"></i> ${
dockerData.error || hostData.error
}</div>`
);
} }
}) })
.catch((error) => { .catch((error) => {

View File

@ -42,7 +42,7 @@ func HandleCreateProxySession(w http.ResponseWriter, r *http.Request) {
if !*allowSshLoopback { if !*allowSshLoopback {
//Not allow loopback connections //Not allow loopback connections
if strings.EqualFold(strings.TrimSpace(ipaddr), "localhost") || strings.TrimSpace(ipaddr) == "127.0.0.1" { if sshprox.IsLoopbackIPOrDomain(ipaddr) {
//Request target is loopback //Request target is loopback
utils.SendErrorResponse(w, "loopback web ssh connection is not enabled on this host") utils.SendErrorResponse(w, "loopback web ssh connection is not enabled on this host")
return return
@ -74,7 +74,7 @@ func HandleCreateProxySession(w http.ResponseWriter, r *http.Request) {
utils.SendJSONResponse(w, string(js)) utils.SendJSONResponse(w, string(js))
} }
//Check if the host support ssh, or if the target domain (and port, optional) support ssh // Check if the host support ssh, or if the target domain (and port, optional) support ssh
func HandleWebSshSupportCheck(w http.ResponseWriter, r *http.Request) { func HandleWebSshSupportCheck(w http.ResponseWriter, r *http.Request) {
domain, err := utils.PostPara(r, "domain") domain, err := utils.PostPara(r, "domain")
if err != nil { if err != nil {