Loops and HTTP control server rework (#308)

- CRUD REST HTTP server
- `/v1` HTTP server prefix
- Retrocompatible with older routes (redirects to v1 or handles the requests directly)
- DNS, Updater and Openvpn refactored to have a REST-like state with new methods to change their states synchronously
- Openvpn, Unbound and Updater status, see #287
This commit is contained in:
Quentin McGaw
2020-12-19 20:10:34 -05:00
committed by GitHub
parent d60d629105
commit 4257581f55
30 changed files with 1191 additions and 438 deletions

76
internal/server/dns.go Normal file
View File

@@ -0,0 +1,76 @@
//nolint:dupl
package server
import (
"encoding/json"
"net/http"
"strings"
"github.com/qdm12/gluetun/internal/dns"
"github.com/qdm12/golibs/logging"
)
func newDNSHandler(looper dns.Looper, logger logging.Logger) http.Handler {
return &dnsHandler{
looper: looper,
logger: logger,
}
}
type dnsHandler struct {
looper dns.Looper
logger logging.Logger
}
func (h *dnsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.RequestURI = strings.TrimPrefix(r.RequestURI, "/dns")
switch r.RequestURI {
case "/status": //nolint:goconst
switch r.Method {
case http.MethodGet:
h.getStatus(w)
case http.MethodPut:
h.setStatus(w, r)
default:
http.Error(w, "", http.StatusNotFound)
}
default:
http.Error(w, "", http.StatusNotFound)
}
}
func (h *dnsHandler) getStatus(w http.ResponseWriter) {
status := h.looper.GetStatus()
encoder := json.NewEncoder(w)
data := statusWrapper{Status: string(status)}
if err := encoder.Encode(data); err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (h *dnsHandler) setStatus(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data statusWrapper
if err := decoder.Decode(&data); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
status, err := data.getStatus()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
outcome, err := h.looper.SetStatus(status)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
encoder := json.NewEncoder(w)
if err := encoder.Encode(outcomeWrapper{Outcome: outcome}); err != nil {
h.logger.Warn(err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}

View File

@@ -1,8 +1,8 @@
package server
import (
"fmt"
"net/http"
"strings"
"github.com/qdm12/gluetun/internal/dns"
"github.com/qdm12/gluetun/internal/models"
@@ -17,54 +17,33 @@ func newHandler(logger logging.Logger, logging bool,
unboundLooper dns.Looper,
updaterLooper updater.Looper,
) http.Handler {
return &handler{
logger: logger,
logging: logging,
buildInfo: buildInfo,
openvpnLooper: openvpnLooper,
unboundLooper: unboundLooper,
updaterLooper: updaterLooper,
}
handler := &handler{}
openvpn := newOpenvpnHandler(openvpnLooper, logger)
dns := newDNSHandler(unboundLooper, logger)
updater := newUpdaterHandler(updaterLooper, logger)
handler.v0 = newHandlerV0(logger, openvpnLooper, unboundLooper, updaterLooper)
handler.v1 = newHandlerV1(logger, buildInfo, openvpn, dns, updater)
handlerWithLog := withLogMiddleware(handler, logger, logging)
handler.setLogEnabled = handlerWithLog.setEnabled
return handlerWithLog
}
type handler struct {
logger logging.Logger
logging bool
buildInfo models.BuildInformation
openvpnLooper openvpn.Looper
unboundLooper dns.Looper
updaterLooper updater.Looper
v0 http.Handler
v1 http.Handler
setLogEnabled func(enabled bool)
}
func (h *handler) ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) {
if h.logging {
h.logger.Info("HTTP %s %s", request.Method, request.RequestURI)
}
switch request.Method {
case http.MethodGet:
switch request.RequestURI {
case "/version":
h.getVersion(responseWriter)
responseWriter.WriteHeader(http.StatusOK)
case "/openvpn/actions/restart":
h.openvpnLooper.Restart()
responseWriter.WriteHeader(http.StatusOK)
case "/unbound/actions/restart":
h.unboundLooper.Restart()
responseWriter.WriteHeader(http.StatusOK)
case "/openvpn/portforwarded":
h.getPortForwarded(responseWriter)
case "/openvpn/settings":
h.getOpenvpnSettings(responseWriter)
case "/updater/restart":
h.updaterLooper.Restart()
responseWriter.WriteHeader(http.StatusOK)
default:
errString := fmt.Sprintf("Nothing here for %s %s", request.Method, request.RequestURI)
http.Error(responseWriter, errString, http.StatusBadRequest)
}
default:
errString := fmt.Sprintf("Nothing here for %s %s", request.Method, request.RequestURI)
http.Error(responseWriter, errString, http.StatusBadRequest)
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.RequestURI = strings.TrimSuffix(r.RequestURI, "/")
if !strings.HasPrefix(r.RequestURI, "/v1/") && r.RequestURI != "/v1" {
h.v0.ServeHTTP(w, r)
return
}
r.RequestURI = strings.TrimPrefix(r.RequestURI, "/v1")
h.v1.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,69 @@
package server
import (
"net/http"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/dns"
"github.com/qdm12/gluetun/internal/openvpn"
"github.com/qdm12/gluetun/internal/updater"
"github.com/qdm12/golibs/logging"
)
func newHandlerV0(logger logging.Logger,
openvpn openvpn.Looper, dns dns.Looper, updater updater.Looper) http.Handler {
return &handlerV0{
logger: logger,
openvpn: openvpn,
dns: dns,
updater: updater,
}
}
type handlerV0 struct {
logger logging.Logger
openvpn openvpn.Looper
dns dns.Looper
updater updater.Looper
}
func (h *handlerV0) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "unversioned API: only supports GET method", http.StatusNotFound)
return
}
switch r.RequestURI {
case "/version":
http.Redirect(w, r, "/v1/version", http.StatusPermanentRedirect)
case "/openvpn/actions/restart":
outcome, _ := h.openvpn.SetStatus(constants.Stopped)
h.logger.Info("openvpn: %s", outcome)
outcome, _ = h.openvpn.SetStatus(constants.Running)
h.logger.Info("openvpn: %s", outcome)
if _, err := w.Write([]byte("openvpn restarted, please consider using the /v1/ API in the future.")); err != nil {
h.logger.Warn(err)
}
case "/unbound/actions/restart":
outcome, _ := h.dns.SetStatus(constants.Stopped)
h.logger.Info("dns: %s", outcome)
outcome, _ = h.dns.SetStatus(constants.Running)
h.logger.Info("dns: %s", outcome)
if _, err := w.Write([]byte("dns restarted, please consider using the /v1/ API in the future.")); err != nil {
h.logger.Warn(err)
}
case "/openvpn/portforwarded":
http.Redirect(w, r, "/v1/openvpn/portforwarded", http.StatusPermanentRedirect)
case "/openvpn/settings":
http.Redirect(w, r, "/v1/openvpn/settings", http.StatusPermanentRedirect)
case "/updater/restart":
outcome, _ := h.updater.SetStatus(constants.Stopped)
h.logger.Info("updater: %s", outcome)
outcome, _ = h.updater.SetStatus(constants.Running)
h.logger.Info("updater: %s", outcome)
if _, err := w.Write([]byte("updater restarted, please consider using the /v1/ API in the future.")); err != nil {
h.logger.Warn(err)
}
default:
http.Error(w, "unversioned API: requested URI not found", http.StatusNotFound)
}
}

View File

@@ -0,0 +1,58 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/golibs/logging"
)
func newHandlerV1(logger logging.Logger, buildInfo models.BuildInformation,
openvpn, dns, updater http.Handler) http.Handler {
return &handlerV1{
logger: logger,
buildInfo: buildInfo,
openvpn: openvpn,
dns: dns,
updater: updater,
}
}
type handlerV1 struct {
logger logging.Logger
buildInfo models.BuildInformation
openvpn http.Handler
dns http.Handler
updater http.Handler
}
func (h *handlerV1) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.RequestURI == "/version" && r.Method == http.MethodGet:
h.getVersion(w)
case strings.HasPrefix(r.RequestURI, "/openvpn"):
h.openvpn.ServeHTTP(w, r)
case strings.HasPrefix(r.RequestURI, "/dns"):
h.dns.ServeHTTP(w, r)
case strings.HasPrefix(r.RequestURI, "/updater"):
h.updater.ServeHTTP(w, r)
default:
errString := fmt.Sprintf("%s %s not found", r.Method, r.RequestURI)
http.Error(w, errString, http.StatusNotFound)
}
}
func (h *handlerV1) getVersion(w http.ResponseWriter) {
data, err := json.Marshal(h.buildInfo)
if err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if _, err := w.Write(data); err != nil {
h.logger.Warn(err)
}
}

75
internal/server/log.go Normal file
View File

@@ -0,0 +1,75 @@
package server
import (
"net/http"
"sync"
"time"
"github.com/qdm12/golibs/logging"
)
func withLogMiddleware(childHandler http.Handler, logger logging.Logger, enabled bool) *logMiddleware {
return &logMiddleware{
childHandler: childHandler,
logger: logger,
timeNow: time.Now,
enabled: enabled,
}
}
type logMiddleware struct {
childHandler http.Handler
logger logging.Logger
timeNow func() time.Time
enabled bool
enabledMu sync.RWMutex
}
func (m *logMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !m.isEnabled() {
m.childHandler.ServeHTTP(w, r)
return
}
tStart := m.timeNow()
statefulWriter := &statefulResponseWriter{httpWriter: w}
m.childHandler.ServeHTTP(statefulWriter, r)
duration := m.timeNow().Sub(tStart)
m.logger.Info("%d %s %s wrote %dB to %s in %s",
statefulWriter.statusCode, r.Method, r.RequestURI, statefulWriter.length, r.RemoteAddr, duration)
}
func (m *logMiddleware) setEnabled(enabled bool) {
m.enabledMu.Lock()
defer m.enabledMu.Unlock()
m.enabled = enabled
}
func (m *logMiddleware) isEnabled() (enabled bool) {
m.enabledMu.RLock()
defer m.enabledMu.RUnlock()
return m.enabled
}
type statefulResponseWriter struct {
httpWriter http.ResponseWriter
statusCode int
length int
}
func (w *statefulResponseWriter) Write(b []byte) (n int, err error) {
n, err = w.httpWriter.Write(b)
if w.statusCode == 0 {
w.statusCode = http.StatusOK
}
w.length += n
return n, err
}
func (w *statefulResponseWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
w.httpWriter.WriteHeader(statusCode)
}
func (w *statefulResponseWriter) Header() http.Header {
return w.httpWriter.Header()
}

View File

@@ -3,34 +3,110 @@ package server
import (
"encoding/json"
"net/http"
"strings"
"github.com/qdm12/gluetun/internal/openvpn"
"github.com/qdm12/golibs/logging"
)
func (h *handler) getPortForwarded(w http.ResponseWriter) {
port := h.openvpnLooper.GetPortForwarded()
data, err := json.Marshal(struct {
Port uint16 `json:"port"`
}{port})
if err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if _, err := w.Write(data); err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
func newOpenvpnHandler(looper openvpn.Looper, logger logging.Logger) http.Handler {
return &openvpnHandler{
looper: looper,
logger: logger,
}
}
func (h *handler) getOpenvpnSettings(w http.ResponseWriter) {
settings := h.openvpnLooper.GetSettings()
data, err := json.Marshal(settings)
if err != nil {
type openvpnHandler struct {
looper openvpn.Looper
logger logging.Logger
}
func (h *openvpnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.RequestURI = strings.TrimPrefix(r.RequestURI, "/openvpn")
switch r.RequestURI {
case "/status":
switch r.Method {
case http.MethodGet:
h.getStatus(w)
case http.MethodPut:
h.setStatus(w, r)
default:
http.Error(w, "", http.StatusNotFound)
}
case "/settings":
switch r.Method {
case http.MethodGet:
h.getSettings(w)
default:
http.Error(w, "", http.StatusNotFound)
}
case "/portforwarded":
switch r.Method {
case http.MethodGet:
h.getPortForwarded(w)
default:
http.Error(w, "", http.StatusNotFound)
}
default:
http.Error(w, "", http.StatusNotFound)
}
}
func (h *openvpnHandler) getStatus(w http.ResponseWriter) {
status := h.looper.GetStatus()
encoder := json.NewEncoder(w)
data := statusWrapper{Status: string(status)}
if err := encoder.Encode(data); err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if _, err := w.Write(data); err != nil {
}
func (h *openvpnHandler) setStatus(w http.ResponseWriter, r *http.Request) { //nolint:dupl
decoder := json.NewDecoder(r.Body)
var data statusWrapper
if err := decoder.Decode(&data); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
status, err := data.getStatus()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
outcome, err := h.looper.SetStatus(status)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
encoder := json.NewEncoder(w)
if err := encoder.Encode(outcomeWrapper{Outcome: outcome}); err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
func (h *openvpnHandler) getSettings(w http.ResponseWriter) {
settings := h.looper.GetSettings()
settings.User = "redacted"
settings.Password = "redacted"
encoder := json.NewEncoder(w)
if err := encoder.Encode(settings); err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (h *openvpnHandler) getPortForwarded(w http.ResponseWriter) {
port := h.looper.GetPortForwarded()
encoder := json.NewEncoder(w)
data := portWrapper{Port: port}
if err := encoder.Encode(data); err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}

View File

@@ -23,7 +23,8 @@ type server struct {
handler http.Handler
}
func New(address string, logging bool, logger logging.Logger, buildInfo models.BuildInformation,
func New(address string, logging bool, logger logging.Logger,
buildInfo models.BuildInformation,
openvpnLooper openvpn.Looper, unboundLooper dns.Looper, updaterLooper updater.Looper) Server {
serverLogger := logger.WithPrefix("http server: ")
handler := newHandler(serverLogger, logging, buildInfo, openvpnLooper, unboundLooper, updaterLooper)

View File

@@ -0,0 +1,78 @@
//nolint:dupl
package server
import (
"encoding/json"
"net/http"
"strings"
"github.com/qdm12/gluetun/internal/updater"
"github.com/qdm12/golibs/logging"
)
func newUpdaterHandler(
looper updater.Looper,
logger logging.Logger) http.Handler {
return &updaterHandler{
looper: looper,
logger: logger,
}
}
type updaterHandler struct {
looper updater.Looper
logger logging.Logger
}
func (h *updaterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.RequestURI = strings.TrimPrefix(r.RequestURI, "/updater")
switch r.RequestURI {
case "/status":
switch r.Method {
case http.MethodGet:
h.getStatus(w)
case http.MethodPut:
h.setStatus(w, r)
default:
http.Error(w, "", http.StatusNotFound)
}
default:
http.Error(w, "", http.StatusNotFound)
}
}
func (h *updaterHandler) getStatus(w http.ResponseWriter) {
status := h.looper.GetStatus()
encoder := json.NewEncoder(w)
data := statusWrapper{Status: string(status)}
if err := encoder.Encode(data); err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (h *updaterHandler) setStatus(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data statusWrapper
if err := decoder.Decode(&data); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
status, err := data.getStatus()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
outcome, err := h.looper.SetStatus(status)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
encoder := json.NewEncoder(w)
if err := encoder.Encode(outcomeWrapper{Outcome: outcome}); err != nil {
h.logger.Warn(err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}

View File

@@ -1,19 +0,0 @@
package server
import (
"encoding/json"
"net/http"
)
func (h *handler) getVersion(w http.ResponseWriter) {
data, err := json.Marshal(h.buildInfo)
if err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if _, err := w.Write(data); err != nil {
h.logger.Warn(err)
w.WriteHeader(http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,32 @@
package server
import (
"fmt"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/models"
)
type statusWrapper struct {
Status string `json:"status"`
}
func (sw *statusWrapper) getStatus() (status models.LoopStatus, err error) {
status = models.LoopStatus(sw.Status)
switch status {
case constants.Stopped, constants.Running:
return status, nil
default:
return "", fmt.Errorf(
"invalid status %q: possible values are: %s, %s",
sw.Status, constants.Stopped, constants.Running)
}
}
type portWrapper struct {
Port uint16 `json:"port"`
}
type outcomeWrapper struct {
Outcome string `json:"outcome"`
}