Subnet range selector, interface fixes (#481)

This commit is contained in:
0xCA 2023-12-27 13:08:55 +05:00 committed by GitHub
parent e73047b14f
commit a9be53899c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 473 additions and 44 deletions

View file

@ -42,6 +42,7 @@ docker-compose up
| `BIND_ADDRESS` | The addresses that can access to the web interface and the port, use unix:///abspath/to/file.socket for unix domain socket. | 0.0.0.0:80 | | `BIND_ADDRESS` | The addresses that can access to the web interface and the port, use unix:///abspath/to/file.socket for unix domain socket. | 0.0.0.0:80 |
| `SESSION_SECRET` | The secret key used to encrypt the session cookies. Set this to a random value | N/A | | `SESSION_SECRET` | The secret key used to encrypt the session cookies. Set this to a random value | N/A |
| `SESSION_SECRET_FILE` | Optional filepath for the secret key used to encrypt the session cookies. Leave `SESSION_SECRET` blank to take effect | N/A | | `SESSION_SECRET_FILE` | Optional filepath for the secret key used to encrypt the session cookies. Leave `SESSION_SECRET` blank to take effect | N/A |
| `SUBNET_RANGES` | The list of address subdivision ranges. Format: `SR Name:10.0.1.0/24; SR2:10.0.2.0/24,10.0.3.0/24` Each CIDR must be inside one of the server interfaces. | N/A |
| `WGUI_USERNAME` | The username for the login page. Used for db initialization only | `admin` | | `WGUI_USERNAME` | The username for the login page. Used for db initialization only | `admin` |
| `WGUI_PASSWORD` | The password for the user on the login page. Will be hashed automatically. Used for db initialization only | `admin` | | `WGUI_PASSWORD` | The password for the user on the login page. Will be hashed automatically. Used for db initialization only | `admin` |
| `WGUI_PASSWORD_FILE` | Optional filepath for the user login password. Will be hashed automatically. Used for db initialization only. Leave `WGUI_PASSWORD` blank to take effect | N/A | | `WGUI_PASSWORD_FILE` | Optional filepath for the user login password. Will be hashed automatically. Used for db initialization only. Leave `WGUI_PASSWORD` blank to take effect | N/A |

View file

@ -18,6 +18,11 @@ function renderClientList(data) {
allowedIpsHtml += `<small class="badge badge-secondary">${obj}</small>&nbsp;`; allowedIpsHtml += `<small class="badge badge-secondary">${obj}</small>&nbsp;`;
}) })
let subnetRangesString = "";
if (obj.Client.subnet_ranges && obj.Client.subnet_ranges.length > 0) {
subnetRangesString = obj.Client.subnet_ranges.join(',')
}
// render client html content // render client html content
let html = `<div class="col-sm-6 col-md-6 col-lg-4" id="client_${obj.Client.id}"> let html = `<div class="col-sm-6 col-md-6 col-lg-4" id="client_${obj.Client.id}">
<div class="info-box"> <div class="info-box">
@ -59,6 +64,7 @@ function renderClientList(data) {
<hr> <hr>
<span class="info-box-text"><i class="fas fa-user"></i> ${obj.Client.name}</span> <span class="info-box-text"><i class="fas fa-user"></i> ${obj.Client.name}</span>
<span class="info-box-text" style="display: none"><i class="fas fa-key"></i> ${obj.Client.public_key}</span> <span class="info-box-text" style="display: none"><i class="fas fa-key"></i> ${obj.Client.public_key}</span>
<span class="info-box-text" style="display: none"><i class="fas fa-subnetrange"></i>${subnetRangesString}</span>
<span class="info-box-text"><i class="fas fa-envelope"></i> ${obj.Client.email}</span> <span class="info-box-text"><i class="fas fa-envelope"></i> ${obj.Client.email}</span>
<span class="info-box-text"><i class="fas fa-clock"></i> <span class="info-box-text"><i class="fas fa-clock"></i>
${prettyDateTime(obj.Client.created_at)}</span> ${prettyDateTime(obj.Client.created_at)}</span>

View file

@ -366,6 +366,10 @@ func GetClients(db store.IStore) echo.HandlerFunc {
}) })
} }
for i, clientData := range clientDataList {
clientDataList[i] = util.FillClientSubnetRange(clientData)
}
return c.JSON(http.StatusOK, clientDataList) return c.JSON(http.StatusOK, clientDataList)
} }
} }
@ -391,7 +395,7 @@ func GetClient(db store.IStore) echo.HandlerFunc {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
} }
return c.JSON(http.StatusOK, clientData) return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData))
} }
} }
@ -988,6 +992,13 @@ func MachineIPAddresses() echo.HandlerFunc {
} }
} }
// GetOrderedSubnetRanges handler to get the ordered list of subnet ranges
func GetOrderedSubnetRanges() echo.HandlerFunc {
return func(c echo.Context) error {
return c.JSON(http.StatusOK, util.SubnetRangesOrder)
}
}
// SuggestIPAllocation handler to get the list of ip address for client // SuggestIPAllocation handler to get the list of ip address for client
func SuggestIPAllocation(db store.IStore) echo.HandlerFunc { func SuggestIPAllocation(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@ -1009,22 +1020,48 @@ func SuggestIPAllocation(db store.IStore) echo.HandlerFunc {
false, "Cannot suggest ip allocation: failed to get list of allocated ip addresses", false, "Cannot suggest ip allocation: failed to get list of allocated ip addresses",
}) })
} }
for _, cidr := range server.Interface.Addresses {
ip, err := util.GetAvailableIP(cidr, allocatedIPs) sr := c.QueryParam("sr")
searchCIDRList := make([]string, 0)
found := false
// Use subnet range or default to interface addresses
if util.SubnetRanges[sr] != nil {
for _, cidr := range util.SubnetRanges[sr] {
searchCIDRList = append(searchCIDRList, cidr.String())
}
} else {
searchCIDRList = append(searchCIDRList, server.Interface.Addresses...)
}
// Save only unique IPs
ipSet := make(map[string]struct{})
for _, cidr := range searchCIDRList {
ip, err := util.GetAvailableIP(cidr, allocatedIPs, server.Interface.Addresses)
if err != nil { if err != nil {
log.Error("Failed to get available ip from a CIDR: ", err) log.Error("Failed to get available ip from a CIDR: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ continue
false,
fmt.Sprintf("Cannot suggest ip allocation: failed to get available ip from network %s", cidr),
})
} }
found = true
if strings.Contains(ip, ":") { if strings.Contains(ip, ":") {
suggestedIPs = append(suggestedIPs, fmt.Sprintf("%s/128", ip)) ipSet[fmt.Sprintf("%s/128", ip)] = struct{}{}
} else { } else {
suggestedIPs = append(suggestedIPs, fmt.Sprintf("%s/32", ip)) ipSet[fmt.Sprintf("%s/32", ip)] = struct{}{}
} }
} }
if !found {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false,
"Cannot suggest ip allocation: failed to get available ip. Try a different subnet or deallocate some ips.",
})
}
for ip := range ipSet {
suggestedIPs = append(suggestedIPs, ip)
}
return c.JSON(http.StatusOK, suggestedIPs) return c.JSON(http.StatusOK, suggestedIPs)
} }
} }

16
main.go
View file

@ -45,6 +45,7 @@ var (
flagSessionSecret string = util.RandomString(32) flagSessionSecret string = util.RandomString(32)
flagWgConfTemplate string flagWgConfTemplate string
flagBasePath string flagBasePath string
flagSubnetRanges string
) )
const ( const (
@ -81,6 +82,7 @@ func init() {
flag.StringVar(&flagEmailFromName, "email-from-name", util.LookupEnvOrString("EMAIL_FROM_NAME", flagEmailFromName), "'From' email name.") flag.StringVar(&flagEmailFromName, "email-from-name", util.LookupEnvOrString("EMAIL_FROM_NAME", flagEmailFromName), "'From' email name.")
flag.StringVar(&flagWgConfTemplate, "wg-conf-template", util.LookupEnvOrString("WG_CONF_TEMPLATE", flagWgConfTemplate), "Path to custom wg.conf template.") flag.StringVar(&flagWgConfTemplate, "wg-conf-template", util.LookupEnvOrString("WG_CONF_TEMPLATE", flagWgConfTemplate), "Path to custom wg.conf template.")
flag.StringVar(&flagBasePath, "base-path", util.LookupEnvOrString("BASE_PATH", flagBasePath), "The base path of the URL") flag.StringVar(&flagBasePath, "base-path", util.LookupEnvOrString("BASE_PATH", flagBasePath), "The base path of the URL")
flag.StringVar(&flagSubnetRanges, "subnet-ranges", util.LookupEnvOrString("SUBNET_RANGES", flagSubnetRanges), "IP ranges to choose from when assigning an IP for a client.")
var ( var (
smtpPasswordLookup = util.LookupEnvOrString("SMTP_PASSWORD", flagSmtpPassword) smtpPasswordLookup = util.LookupEnvOrString("SMTP_PASSWORD", flagSmtpPassword)
@ -127,6 +129,7 @@ func init() {
util.SessionSecret = []byte(flagSessionSecret) util.SessionSecret = []byte(flagSessionSecret)
util.WgConfTemplate = flagWgConfTemplate util.WgConfTemplate = flagWgConfTemplate
util.BasePath = util.ParseBasePath(flagBasePath) util.BasePath = util.ParseBasePath(flagBasePath)
util.SubnetRanges = util.ParseSubnetRanges(flagSubnetRanges)
// print only if log level is INFO or lower // print only if log level is INFO or lower
if lvl, _ := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO")); lvl <= log.INFO { if lvl, _ := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO")); lvl <= log.INFO {
@ -145,6 +148,7 @@ func init() {
//fmt.Println("Session secret\t:", util.SessionSecret) //fmt.Println("Session secret\t:", util.SessionSecret)
fmt.Println("Custom wg.conf\t:", util.WgConfTemplate) fmt.Println("Custom wg.conf\t:", util.WgConfTemplate)
fmt.Println("Base path\t:", util.BasePath+"/") fmt.Println("Base path\t:", util.BasePath+"/")
fmt.Println("Subnet ranges\t:", util.GetSubnetRangesString())
} }
} }
@ -170,6 +174,17 @@ func main() {
// create the wireguard config on start, if it doesn't exist // create the wireguard config on start, if it doesn't exist
initServerConfig(db, tmplDir) initServerConfig(db, tmplDir)
// Check if subnet ranges are valid for the server configuration
// Remove any non-valid CIDRs
if err := util.ValidateAndFixSubnetRanges(db); err != nil {
panic(err)
}
// Print valid ranges
if lvl, _ := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO")); lvl <= log.INFO {
fmt.Println("Valid subnet ranges:", util.GetSubnetRangesString())
}
// register routes // register routes
app := router.New(tmplDir, extraData, util.SessionSecret) app := router.New(tmplDir, extraData, util.SessionSecret)
@ -218,6 +233,7 @@ func main() {
app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession) app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession)
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession) app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession)
app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession) app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession)
app.GET(util.BasePath+"/api/subnet-ranges", handler.GetOrderedSubnetRanges(), handler.ValidSession)
app.GET(util.BasePath+"/api/suggest-client-ips", handler.SuggestIPAllocation(db), handler.ValidSession) app.GET(util.BasePath+"/api/suggest-client-ips", handler.SuggestIPAllocation(db), handler.ValidSession)
app.POST(util.BasePath+"/api/apply-wg-config", handler.ApplyServerConfig(db, tmplDir), handler.ValidSession, handler.ContentTypeJson) app.POST(util.BasePath+"/api/apply-wg-config", handler.ApplyServerConfig(db, tmplDir), handler.ValidSession, handler.ContentTypeJson)
app.GET(util.BasePath+"/wake_on_lan_hosts", handler.GetWakeOnLanHosts(db), handler.ValidSession) app.GET(util.BasePath+"/wake_on_lan_hosts", handler.GetWakeOnLanHosts(db), handler.ValidSession)

View file

@ -12,6 +12,7 @@ type Client struct {
PresharedKey string `json:"preshared_key"` PresharedKey string `json:"preshared_key"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
SubnetRanges []string `json:"subnet_ranges,omitempty"`
AllocatedIPs []string `json:"allocated_ips"` AllocatedIPs []string `json:"allocated_ips"`
AllowedIPs []string `json:"allowed_ips"` AllowedIPs []string `json:"allowed_ips"`
ExtraAllowedIPs []string `json:"extra_allowed_ips"` ExtraAllowedIPs []string `json:"extra_allowed_ips"`

View file

@ -58,11 +58,13 @@
</div> </div>
<div class="form-group form-group-sm"> <div class="form-group form-group-sm">
<select name="status-selector" id="status-selector" class="custom-select form-control-navbar" style="margin-left: 0.5em; height: 90%; font-size: 14px;"> <select name="status-selector" id="status-selector" class="custom-select form-control-navbar" style="margin-left: 0.5em; height: 90%; font-size: 14px;">
<!-- THIS SECTION IS OVERRIDDEN BY JS. SEE updateSearchList() function in clients.html BEFORE EDITING -->
<option value="All">All</option> <option value="All">All</option>
<option value="Enabled">Enabled</option> <option value="Enabled">Enabled</option>
<option value="Disabled">Disabled</option> <option value="Disabled">Disabled</option>
<option value="Connected">Connected</option> <option value="Connected">Connected</option>
<option value="Disconnected">Disconnected</option> <option value="Disconnected">Disconnected</option>
<!-- THIS SECTION IS OVERRIDDEN BY JS. SEE updateSearchList() function in clients.html BEFORE EDITING -->
</select> </select>
</div> </div>
</form> </form>
@ -209,6 +211,12 @@
<label for="client_email" class="control-label">Email</label> <label for="client_email" class="control-label">Email</label>
<input type="text" class="form-control" id="client_email" name="client_email"> <input type="text" class="form-control" id="client_email" name="client_email">
</div> </div>
<div class="form-group">
<label for="subnet_ranges" class="control-label">Subnet range</label>
<select id="subnet_ranges" class="select2"
data-placeholder="Select a subnet range" style="width: 100%;">
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="client_allocated_ips" class="control-label">IP Allocation</label> <label for="client_allocated_ips" class="control-label">IP Allocation</label>
<input type="text" data-role="tagsinput" class="form-control" id="client_allocated_ips"> <input type="text" data-role="tagsinput" class="form-control" id="client_allocated_ips">
@ -368,6 +376,36 @@
$(document).ready(function () { $(document).ready(function () {
addGlobalStyle(`
.toast-top-right-fix {
top: 67px;
right: 12px;
}
`, 'toastrToastStyleFix')
toastr.options.closeDuration = 100;
// toastr.options.timeOut = 10000;
toastr.options.positionClass = 'toast-top-right-fix';
updateApplyConfigVisibility()
// from clients.html
updateSearchList()
});
function addGlobalStyle(css, id) {
if (!document.querySelector('#' + id)) {
let head = document.head
if (!head) { return }
let style = document.createElement('style')
style.type = 'text/css'
style.id = id
style.innerHTML = css
head.appendChild(style)
}
}
function updateApplyConfigVisibility() {
$.ajax({ $.ajax({
cache: false, cache: false,
method: 'GET', method: 'GET',
@ -388,8 +426,7 @@
toastr.error(responseJson['message']); toastr.error(responseJson['message']);
} }
}); });
}
});
// populateClient function for render new client info // populateClient function for render new client info
@ -456,6 +493,7 @@
if (window.location.pathname === "{{.basePath}}/") { if (window.location.pathname === "{{.basePath}}/") {
populateClient(resp.id); populateClient(resp.id);
} }
updateApplyConfigVisibility()
}, },
error: function(jqXHR, exception) { error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText); const responseJson = jQuery.parseJSON(jqXHR.responseText);
@ -466,19 +504,32 @@
// updateIPAllocationSuggestion function for automatically fill // updateIPAllocationSuggestion function for automatically fill
// the IP Allocation input with suggested ip addresses // the IP Allocation input with suggested ip addresses
function updateIPAllocationSuggestion() { function updateIPAllocationSuggestion(forceDefault = false) {
let subnetRange = $("#subnet_ranges").select2('val');
if (forceDefault || !subnetRange || subnetRange.length === 0) {
subnetRange = '__default_any__'
}
$.ajax({ $.ajax({
cache: false, cache: false,
method: 'GET', method: 'GET',
url: '{{.basePath}}/api/suggest-client-ips', url: `{{.basePath}}/api/suggest-client-ips?sr=${subnetRange}`,
dataType: 'json', dataType: 'json',
contentType: "application/json", contentType: "application/json",
success: function(data) { success: function(data) {
const allocated_ips = $("#client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#client_allocated_ips').removeTag(escape(item));
})
data.forEach(function (item, index) { data.forEach(function (item, index) {
$('#client_allocated_ips').addTag(item); $('#client_allocated_ips').addTag(item);
}) })
}, },
error: function(jqXHR, exception) { error: function(jqXHR, exception) {
const allocated_ips = $("#client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#client_allocated_ips').removeTag(escape(item));
})
const responseJson = jQuery.parseJSON(jqXHR.responseText); const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']); toastr.error(responseJson['message']);
} }
@ -497,7 +548,6 @@
'defaultText': 'Add More', 'defaultText': 'Add More',
'removeWithBackspace': true, 'removeWithBackspace': true,
'minChars': 0, 'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666' 'placeholderColor': '#666666'
}); });
@ -509,7 +559,6 @@
'defaultText': 'Add More', 'defaultText': 'Add More',
'removeWithBackspace': true, 'removeWithBackspace': true,
'minChars': 0, 'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666' 'placeholderColor': '#666666'
}); });
@ -520,7 +569,6 @@
'defaultText': 'Add More', 'defaultText': 'Add More',
'removeWithBackspace': true, 'removeWithBackspace': true,
'minChars': 0, 'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666' 'placeholderColor': '#666666'
}); });
@ -565,10 +613,17 @@
$("#client_preshared_key").val(""); $("#client_preshared_key").val("");
$("#client_allocated_ips").importTags(''); $("#client_allocated_ips").importTags('');
$("#client_extra_allowed_ips").importTags(''); $("#client_extra_allowed_ips").importTags('');
updateIPAllocationSuggestion(); updateSubnetRangesList("#subnet_ranges");
updateIPAllocationSuggestion(true);
}); });
}); });
// handle subnet range select
$('#subnet_ranges').on('select2:select', function (e) {
// console.log('Selected Option: ', $("#subnet_ranges").select2('val'));
updateIPAllocationSuggestion();
});
// apply_config_confirm button event // apply_config_confirm button event
$(document).ready(function () { $(document).ready(function () {
$("#apply_config_confirm").click(function () { $("#apply_config_confirm").click(function () {
@ -579,6 +634,7 @@
dataType: 'json', dataType: 'json',
contentType: "application/json", contentType: "application/json",
success: function(data) { success: function(data) {
updateApplyConfigVisibility()
$("#modal_apply_config").modal('hide'); $("#modal_apply_config").modal('hide');
toastr.success('Applied config successfully'); toastr.success('Applied config successfully');
}, },

View file

@ -100,6 +100,12 @@ Wireguard Clients
<label for="_client_email" class="control-label">Email</label> <label for="_client_email" class="control-label">Email</label>
<input type="text" class="form-control" id="_client_email" name="client_email"> <input type="text" class="form-control" id="_client_email" name="client_email">
</div> </div>
<div class="form-group">
<label for="_subnet_ranges" class="control-label">Subnet range</label>
<select id="_subnet_ranges" class="select2"
data-placeholder="Select a subnet range" style="width: 100%;">
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="_client_allocated_ips" class="control-label">IP Allocation</label> <label for="_client_allocated_ips" class="control-label">IP Allocation</label>
<input type="text" data-role="tagsinput" class="form-control" id="_client_allocated_ips"> <input type="text" data-role="tagsinput" class="form-control" id="_client_allocated_ips">
@ -253,13 +259,102 @@ Wireguard Clients
setClientStatus(clientID, true); setClientStatus(clientID, true);
const divElement = document.getElementById("paused_" + clientID); const divElement = document.getElementById("paused_" + clientID);
divElement.style.visibility = "hidden"; divElement.style.visibility = "hidden";
updateApplyConfigVisibility()
} }
function pauseClient(clientID) { function pauseClient(clientID) {
setClientStatus(clientID, false); setClientStatus(clientID, false);
const divElement = document.getElementById("paused_" + clientID); const divElement = document.getElementById("paused_" + clientID);
divElement.style.visibility = "visible"; divElement.style.visibility = "visible";
updateApplyConfigVisibility()
} }
// updateIPAllocationSuggestion function for automatically fill
// the IP Allocation input with suggested ip addresses
// FOR CHANGING A SUBNET OF AN EXISTING CLIENT
function updateIPAllocationSuggestionExisting() {
let subnetRange = $("#_subnet_ranges").select2('val');
if (!subnetRange || subnetRange.length === 0) {
subnetRange = '__default_any__'
}
$.ajax({
cache: false,
method: 'GET',
url: `{{.basePath}}/api/suggest-client-ips?sr=${subnetRange}`,
dataType: 'json',
contentType: "application/json",
success: function(data) {
const allocated_ips = $("#_client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#_client_allocated_ips').removeTag(escape(item));
})
data.forEach(function (item, index) {
$('#_client_allocated_ips').addTag(item);
})
},
error: function(jqXHR, exception) {
const allocated_ips = $("#_client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#_client_allocated_ips').removeTag(escape(item));
})
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
function updateSubnetRangesList(elementID, preselectedVal) {
$.getJSON("{{.basePath}}/api/subnet-ranges", null, function(data) {
$(`${elementID} option`).remove();
$(elementID).append(
$("<option></option>")
.text("Any")
.val("__default_any__")
);
$.each(data, function(index, item) {
$(elementID).append(
$("<option></option>")
.text(item)
.val(item)
);
if (item === preselectedVal) {
console.log(preselectedVal);
$(elementID).val(preselectedVal).trigger('change')
}
});
});
}
function updateSearchList() {
$.getJSON("{{.basePath}}/api/subnet-ranges", null, function(data) {
$("#status-selector option").remove();
$("#status-selector").append(
$("<option></option>")
.text("All")
.val("All"),
$("<option></option>")
.text("Enabled")
.val("Enabled"),
$("<option></option>")
.text("Disabled")
.val("Disabled"),
$("<option></option>")
.text("Connected")
.val("Connected"),
$("<option></option>")
.text("Disconnected")
.val("Disconnected")
);
$.each(data, function(index, item) {
$("#status-selector").append(
$("<option></option>")
.text(item)
.val(item)
);
});
});
}
</script> </script>
<script> <script>
// load client list // load client list
@ -349,7 +444,18 @@ Wireguard Clients
}); });
break; break;
default: default:
$('.col-lg-4').show(); $('.col-lg-4').hide();
const selectedSR = $("#status-selector").val()
$(".fa-subnetrange").each(function () {
const srs = $(this).parent().text().trim().split(',')
for (const sr of srs) {
if (sr === selectedSR) {
$(this).closest('.col-lg-4').show();
break
}
}
})
// $('.col-lg-4').show();
break; break;
} }
}); });
@ -400,6 +506,7 @@ Wireguard Clients
toastr.success('Removed client successfully'); toastr.success('Removed client successfully');
const divElement = document.getElementById('client_' + client_id); const divElement = document.getElementById('client_' + client_id);
divElement.style.display = "none"; divElement.style.display = "none";
updateApplyConfigVisibility()
}, },
error: function(jqXHR, exception) { error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText); const responseJson = jQuery.parseJSON(jqXHR.responseText);
@ -427,7 +534,6 @@ Wireguard Clients
'defaultText': 'Add More', 'defaultText': 'Add More',
'removeWithBackspace': true, 'removeWithBackspace': true,
'minChars': 0, 'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666' 'placeholderColor': '#666666'
}); });
@ -439,7 +545,6 @@ Wireguard Clients
'defaultText': 'Add More', 'defaultText': 'Add More',
'removeWithBackspace': true, 'removeWithBackspace': true,
'minChars': 0, 'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666' 'placeholderColor': '#666666'
}); });
@ -450,7 +555,6 @@ Wireguard Clients
'defaultText': 'Add More', 'defaultText': 'Add More',
'removeWithBackspace' : true, 'removeWithBackspace' : true,
'minChars': 0, 'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666' 'placeholderColor': '#666666'
}) })
@ -469,6 +573,13 @@ Wireguard Clients
modal.find("#_client_name").val(client.name); modal.find("#_client_name").val(client.name);
modal.find("#_client_email").val(client.email); modal.find("#_client_email").val(client.email);
let preselectedEl
if (client.subnet_ranges && client.subnet_ranges.length > 0) {
preselectedEl = client.subnet_ranges[0]
}
updateSubnetRangesList("#_subnet_ranges", preselectedEl);
modal.find("#_client_allocated_ips").importTags(''); modal.find("#_client_allocated_ips").importTags('');
client.allocated_ips.forEach(function (obj) { client.allocated_ips.forEach(function (obj) {
modal.find("#_client_allocated_ips").addTag(obj); modal.find("#_client_allocated_ips").addTag(obj);
@ -491,6 +602,11 @@ Wireguard Clients
modal.find("#_client_public_key").val(client.public_key); modal.find("#_client_public_key").val(client.public_key);
modal.find("#_client_preshared_key").val(client.preshared_key); modal.find("#_client_preshared_key").val(client.preshared_key);
// handle subnet range select
$('#_subnet_ranges').on('select2:select', function (e) {
updateIPAllocationSuggestionExisting();
});
}, },
error: function (jqXHR, exception) { error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText); const responseJson = jQuery.parseJSON(jqXHR.responseText);

View file

@ -203,7 +203,6 @@ Global Settings
'defaultText': 'Add More', 'defaultText': 'Add More',
'removeWithBackspace': true, 'removeWithBackspace': true,
'minChars': 0, 'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666' 'placeholderColor': '#666666'
}); });

View file

@ -167,7 +167,6 @@ Wireguard Server Settings
'defaultText': 'Add More', 'defaultText': 'Add More',
'removeWithBackspace': true, 'removeWithBackspace': true,
'minChars': 0, 'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666' 'placeholderColor': '#666666'
}); });

3
util/cache.go Normal file
View file

@ -0,0 +1,3 @@
package util
var IPToSubnetRange = map[string]uint16{}

View file

@ -1,24 +1,31 @@
package util package util
import "strings" import (
"net"
"strings"
"github.com/labstack/gommon/log"
)
// Runtime config // Runtime config
var ( var (
DisableLogin bool DisableLogin bool
BindAddress string BindAddress string
SmtpHostname string SmtpHostname string
SmtpPort int SmtpPort int
SmtpUsername string SmtpUsername string
SmtpPassword string SmtpPassword string
SmtpNoTLSCheck bool SmtpNoTLSCheck bool
SmtpEncryption string SmtpEncryption string
SmtpAuthType string SmtpAuthType string
SendgridApiKey string SendgridApiKey string
EmailFrom string EmailFrom string
EmailFromName string EmailFromName string
SessionSecret []byte SessionSecret []byte
WgConfTemplate string WgConfTemplate string
BasePath string BasePath string
SubnetRanges map[string]([]*net.IPNet)
SubnetRangesOrder []string
) )
const ( const (
@ -30,7 +37,7 @@ const (
DefaultDNS = "1.1.1.1" DefaultDNS = "1.1.1.1"
DefaultMTU = 1450 DefaultMTU = 1450
DefaultPersistentKeepalive = 15 DefaultPersistentKeepalive = 15
DefaultFirewallMark = "0xca6c" // i.e. 51820 DefaultFirewallMark = "0xca6c" // i.e. 51820
DefaultTable = "auto" DefaultTable = "auto"
DefaultConfigFilePath = "/etc/wireguard/wg0.conf" DefaultConfigFilePath = "/etc/wireguard/wg0.conf"
UsernameEnvVar = "WGUI_USERNAME" UsernameEnvVar = "WGUI_USERNAME"
@ -66,3 +73,45 @@ func ParseBasePath(basePath string) string {
} }
return basePath return basePath
} }
func ParseSubnetRanges(subnetRangesStr string) map[string]([]*net.IPNet) {
subnetRanges := map[string]([]*net.IPNet){}
if subnetRangesStr == "" {
return subnetRanges
}
cidrSet := map[string]bool{}
subnetRangesStr = strings.TrimSpace(subnetRangesStr)
subnetRangesStr = strings.Trim(subnetRangesStr, ";:,")
ranges := strings.Split(subnetRangesStr, ";")
for _, rng := range ranges {
rng = strings.TrimSpace(rng)
rngSpl := strings.Split(rng, ":")
if len(rngSpl) != 2 {
log.Warnf("Unable to parse subnet range: %v. Skipped.", rng)
continue
}
rngName := strings.TrimSpace(rngSpl[0])
subnetRanges[rngName] = make([]*net.IPNet, 0)
cidrs := strings.Split(rngSpl[1], ",")
for _, cidr := range cidrs {
cidr = strings.TrimSpace(cidr)
_, net, err := net.ParseCIDR(cidr)
if err != nil {
log.Warnf("[%v] Unable to parse CIDR: %v. Skipped.", rngName, cidr)
continue
}
if cidrSet[net.String()] {
log.Warnf("[%v] CIDR already exists: %v. Skipped.", rngName, net.String())
continue
}
cidrSet[net.String()] = true
subnetRanges[rngName] = append(subnetRanges[rngName], net)
}
if len(subnetRanges[rngName]) == 0 {
delete(subnetRanges, rngName)
} else {
SubnetRangesOrder = append(SubnetRangesOrder, rngName)
}
}
return subnetRanges
}

View file

@ -95,6 +95,15 @@ func ClientDefaultsFromEnv() model.ClientDefaults {
return clientDefaults return clientDefaults
} }
// ContainsCIDR to check if ipnet1 contains ipnet2
// https://stackoverflow.com/a/40406619/6111641
// https://go.dev/play/p/Q4J-JEN3sF
func ContainsCIDR(ipnet1, ipnet2 *net.IPNet) bool {
ones1, _ := ipnet1.Mask.Size()
ones2, _ := ipnet2.Mask.Size()
return ones1 <= ones2 && ipnet1.Contains(ipnet2.IP)
}
// ValidateCIDR to validate a network CIDR // ValidateCIDR to validate a network CIDR
func ValidateCIDR(cidr string) bool { func ValidateCIDR(cidr string) bool {
_, _, err := net.ParseCIDR(cidr) _, _, err := net.ParseCIDR(cidr)
@ -317,15 +326,32 @@ func GetBroadcastIP(n *net.IPNet) net.IP {
return broadcast return broadcast
} }
// GetBroadcastAndNetworkAddrsLookup get the ip address that can't be used with current server interfaces
func GetBroadcastAndNetworkAddrsLookup(interfaceAddresses []string) map[string]bool {
list := make(map[string]bool, 0)
for _, ifa := range interfaceAddresses {
_, net, err := net.ParseCIDR(ifa)
if err != nil {
continue
}
broadcastAddr := GetBroadcastIP(net).String()
networkAddr := net.IP.String()
list[broadcastAddr] = true
list[networkAddr] = true
}
return list
}
// GetAvailableIP get the ip address that can be allocated from an CIDR // GetAvailableIP get the ip address that can be allocated from an CIDR
func GetAvailableIP(cidr string, allocatedList []string) (string, error) { // We need interfaceAddresses to find real broadcast and network addresses
func GetAvailableIP(cidr string, allocatedList, interfaceAddresses []string) (string, error) {
ip, net, err := net.ParseCIDR(cidr) ip, net, err := net.ParseCIDR(cidr)
if err != nil { if err != nil {
return "", err return "", err
} }
broadcastAddr := GetBroadcastIP(net).String() unavailableIPs := GetBroadcastAndNetworkAddrsLookup(interfaceAddresses)
networkAddr := net.IP.String()
for ip := ip.Mask(net.Mask); net.Contains(ip); inc(ip) { for ip := ip.Mask(net.Mask); net.Contains(ip); inc(ip) {
available := true available := true
@ -336,7 +362,7 @@ func GetAvailableIP(cidr string, allocatedList []string) (string, error) {
break break
} }
} }
if available && suggestedAddr != networkAddr && suggestedAddr != broadcastAddr { if available && !unavailableIPs[suggestedAddr] {
return suggestedAddr, nil return suggestedAddr, nil
} }
} }
@ -384,6 +410,126 @@ func ValidateIPAllocation(serverAddresses []string, ipAllocatedList []string, ip
return true, nil return true, nil
} }
// findSubnetRangeForIP to find first SR for IP, and cache the match
func findSubnetRangeForIP(cidr string) (uint16, error) {
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return 0, err
}
if srName, ok := IPToSubnetRange[ip.String()]; ok {
return srName, nil
}
for srIndex, sr := range SubnetRangesOrder {
for _, srCIDR := range SubnetRanges[sr] {
if srCIDR.Contains(ip) {
IPToSubnetRange[ip.String()] = uint16(srIndex)
return uint16(srIndex), nil
}
}
}
return 0, fmt.Errorf("Subnet range not found for this IP")
}
// FillClientSubnetRange to fill subnet ranges client belongs to, does nothing if SRs are not found
func FillClientSubnetRange(client model.ClientData) model.ClientData {
cl := *client.Client
for _, ip := range cl.AllocatedIPs {
sr, err := findSubnetRangeForIP(ip)
if err != nil {
continue
}
cl.SubnetRanges = append(cl.SubnetRanges, SubnetRangesOrder[sr])
}
return model.ClientData{
Client: &cl,
QRCode: client.QRCode,
}
}
// ValidateAndFixSubnetRanges to check if subnet ranges are valid for the server configuration
// Removes all non-valid CIDRs
func ValidateAndFixSubnetRanges(db store.IStore) error {
if len(SubnetRangesOrder) == 0 {
return nil
}
server, err := db.GetServer()
if err != nil {
return err
}
var serverSubnets []*net.IPNet
for _, addr := range server.Interface.Addresses {
addr = strings.TrimSpace(addr)
_, net, err := net.ParseCIDR(addr)
if err != nil {
return err
}
serverSubnets = append(serverSubnets, net)
}
for _, rng := range SubnetRangesOrder {
cidrs := SubnetRanges[rng]
if len(cidrs) > 0 {
newCIDRs := make([]*net.IPNet, 0)
for _, cidr := range cidrs {
valid := false
for _, serverSubnet := range serverSubnets {
if ContainsCIDR(serverSubnet, cidr) {
valid = true
break
}
}
if valid {
newCIDRs = append(newCIDRs, cidr)
} else {
log.Warnf("[%v] CIDR is outside of all server subnets: %v. Removed.", rng, cidr)
}
}
if len(newCIDRs) > 0 {
SubnetRanges[rng] = newCIDRs
} else {
delete(SubnetRanges, rng)
log.Warnf("[%v] No valid CIDRs in this subnet range. Removed.", rng)
}
}
}
return nil
}
// GetSubnetRangesString to get a formatted string, representing active subnet ranges
func GetSubnetRangesString() string {
if len(SubnetRangesOrder) == 0 {
return ""
}
strB := strings.Builder{}
for _, rng := range SubnetRangesOrder {
cidrs := SubnetRanges[rng]
if len(cidrs) > 0 {
strB.WriteString(rng)
strB.WriteString(":[")
first := true
for _, cidr := range cidrs {
if !first {
strB.WriteString(", ")
}
strB.WriteString(cidr.String())
first = false
}
strB.WriteString("] ")
}
}
return strings.TrimSpace(strB.String())
}
// WriteWireGuardServerConfig to write Wireguard server config. e.g. wg0.conf // WriteWireGuardServerConfig to write Wireguard server config. e.g. wg0.conf
func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, clientDataList []model.ClientData, usersList []model.User, globalSettings model.GlobalSetting) error { func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, clientDataList []model.ClientData, usersList []model.User, globalSettings model.GlobalSetting) error {
var tmplWireguardConf string var tmplWireguardConf string