feat: network configuration manager

This commit is contained in:
Siyuan Miao 2025-08-13 02:28:21 +02:00
parent 4275d0957a
commit 7f6b937945
30 changed files with 1902 additions and 541 deletions

View File

@ -22,6 +22,8 @@ BIN_DIR := $(shell pwd)/bin
TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u) TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u)
TEST_FORMAT := testdox
hash_resource: hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
@ -59,6 +61,7 @@ build_dev_test: build_test2json build_gotestsum
chmod +x $(BIN_DIR)/tests/run_all_tests; \ chmod +x $(BIN_DIR)/tests/run_all_tests; \
cp $(BIN_DIR)/test2json $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/test2json; \ cp $(BIN_DIR)/test2json $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/test2json; \
cp $(BIN_DIR)/gotestsum $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/gotestsum; \ cp $(BIN_DIR)/gotestsum $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/gotestsum; \
echo $(TEST_FORMAT) > $(BIN_DIR)/tests/.test_format; \
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests . tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
frontend: frontend:

View File

@ -45,6 +45,7 @@ LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
RUN_GO_TESTS=false RUN_GO_TESTS=false
RUN_GO_TESTS_ONLY=false RUN_GO_TESTS_ONLY=false
INSTALL_APP=false INSTALL_APP=false
TEST_FORMAT=${TEST_FORMAT:-"testdox"}
# Parse command line arguments # Parse command line arguments
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -105,7 +106,7 @@ fi
if [ "$RUN_GO_TESTS" = true ]; then if [ "$RUN_GO_TESTS" = true ]; then
msg_info "▶ Building go tests" msg_info "▶ Building go tests"
make build_dev_test make TEST_FORMAT=${TEST_FORMAT} build_dev_test
msg_info "▶ Copying device-tests.tar.gz to remote host" msg_info "▶ Copying device-tests.tar.gz to remote host"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
@ -116,7 +117,7 @@ set -e
TMP_DIR=$(mktemp -d) TMP_DIR=$(mktemp -d)
cd ${TMP_DIR} cd ${TMP_DIR}
tar zxf /tmp/device-tests.tar.gz tar zxf /tmp/device-tests.tar.gz
./gotestsum --format=testdox \ ./gotestsum --format=$(cat .test_format) \
--jsonfile=/tmp/device-tests.json \ --jsonfile=/tmp/device-tests.json \
--post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \ --post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \
--raw-command -- ./run_all_tests -json --raw-command -- ./run_all_tests -json

17
go.mod
View File

@ -8,13 +8,14 @@ require (
github.com/coder/websocket v1.8.13 github.com/coder/websocket v1.8.13
github.com/coreos/go-oidc/v3 v3.14.1 github.com/coreos/go-oidc/v3 v3.14.1
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6 github.com/gin-contrib/logger v1.2.6
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/go-co-op/gocron/v2 v2.16.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.0 github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341
github.com/hanwen/go-fuse/v2 v2.8.0 github.com/hanwen/go-fuse/v2 v2.8.0
github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f
github.com/pion/logging v0.2.4 github.com/pion/logging v0.2.4
github.com/pion/mdns/v2 v2.0.7 github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.1.3 github.com/pion/webrtc/v4 v4.1.3
@ -28,9 +29,9 @@ require (
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netlink v1.3.1
go.bug.st/serial v1.6.4 go.bug.st/serial v1.6.4
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.40.0
golang.org/x/net v0.41.0 golang.org/x/net v0.41.0
golang.org/x/sys v0.33.0 golang.org/x/sys v0.34.0
) )
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
@ -50,15 +51,20 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/packet v1.1.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/pilebones/go-udev v0.9.1 // indirect github.com/pilebones/go-udev v0.9.1 // indirect
github.com/pion/datachannel v1.5.10 // indirect github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect github.com/pion/dtls/v3 v3.0.6 // indirect
@ -75,14 +81,17 @@ require (
github.com/pion/turn/v4 v4.0.2 // indirect github.com/pion/turn/v4 v4.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/wlynxg/anet v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.18.0 // indirect golang.org/x/arch v0.18.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/text v0.26.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

42
go.sum
View File

@ -28,8 +28,6 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/logger v1.2.6 h1:EPolruKUTzNXMVBD9LuAFQmRjTs7AH7yKGuXgYqrKWc= github.com/gin-contrib/logger v1.2.6 h1:EPolruKUTzNXMVBD9LuAFQmRjTs7AH7yKGuXgYqrKWc=
@ -38,6 +36,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -62,6 +62,15 @@ github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoN
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs= github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f h1:dd33oobuIv9PcBVqvbEiCXEbNTomOHyj3WFuC5YiPRU=
github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBeJssZ1YBCMZ5Lzu1pX4vhftDvU10WUVb1uXKtM=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@ -88,6 +97,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -99,6 +112,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8= github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo= github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
@ -146,6 +161,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE= github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE= github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@ -156,6 +173,8 @@ github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzr
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -165,6 +184,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
@ -175,23 +196,28 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -379,6 +379,18 @@ func (f *FieldConfig) validateSingleValue(val string, index int) error {
} }
switch validateType { switch validateType {
case "int":
if _, err := strconv.Atoi(val); err != nil {
return fmt.Errorf("field `%s` is not a valid integer: %s", f.Name, val)
}
case "ipv6_prefix_length":
valInt, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", f.Name, val)
}
if valInt < 0 || valInt > 128 {
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", f.Name, val)
}
case "ipv4": case "ipv4":
if net.ParseIP(val).To4() == nil { if net.ParseIP(val).To4() == nil {
return fmt.Errorf("%s is not a valid IPv4 address: %s", fieldRef, val) return fmt.Errorf("%s is not a valid IPv4 address: %s", fieldRef, val)

View File

@ -25,7 +25,7 @@ type testIPv4StaticConfig struct {
type testIPv6StaticConfig struct { type testIPv6StaticConfig struct {
Address null.String `json:"address" validate_type:"ipv6" required:"true"` Address null.String `json:"address" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix" validate_type:"ipv6" required:"true"` PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" required:"true"`
Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"` Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns" validate_type:"ipv6" required:"true"` DNS []string `json:"dns" validate_type:"ipv6" required:"true"`
} }

424
internal/dhclient/client.go Normal file
View File

@ -0,0 +1,424 @@
package dhclient
import (
"context"
"errors"
"fmt"
"net"
"slices"
"sync"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
const (
VendorIdentifier = "jetkvm"
)
var (
logger = logging.GetSubsystemLogger("dhclient")
ErrIPv6LinkTimeout = errors.New("timeout after waiting for a non-tentative IPv6 address")
ErrIPv6RouteTimeout = errors.New("timeout after waiting for an IPv6 route")
ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up")
ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up")
)
type LeaseChangeHandler func(lease *Lease)
// Config is a DHCP client configuration.
type Config struct {
LinkUpTimeout time.Duration
// Timeout is the timeout for one DHCP request attempt.
Timeout time.Duration
// Retries is how many times to retry DHCP attempts.
Retries int
// IPv4 is whether to request an IPv4 lease.
IPv4 bool
// IPv6 is whether to request an IPv6 lease.
IPv6 bool
// Modifiers4 allows modifications to the IPv4 DHCP request.
Modifiers4 []dhcpv4.Modifier
// Modifiers6 allows modifications to the IPv6 DHCP request.
Modifiers6 []dhcpv6.Modifier
// V6ServerAddr can be a unicast or broadcast destination for DHCPv6
// messages.
//
// If not set, it will default to nclient6's default (all servers &
// relay agents).
V6ServerAddr *net.UDPAddr
// V6ClientPort is the port that is used to send and receive DHCPv6
// messages.
//
// If not set, it will default to dhcpv6's default (546).
V6ClientPort *int
// V4ServerAddr can be a unicast or broadcast destination for IPv4 DHCP
// messages.
//
// If not set, it will default to nclient4's default (DHCP broadcast
// address).
V4ServerAddr *net.UDPAddr
// If true, add Client Identifier (61) option to the IPv4 request.
V4ClientIdentifier bool
OnLease4Change LeaseChangeHandler
OnLease6Change LeaseChangeHandler
}
type Client struct {
ifaces []netlink.Link
cfg Config
l *zerolog.Logger
ctx context.Context
// TODO: support multiple interfaces
currentLease4 *Lease
currentLease6 *Lease
mu sync.Mutex
cfgMu sync.Mutex
lease4Mu sync.Mutex
lease6Mu sync.Mutex
scheduler gocron.Scheduler
}
// NewClient creates a new DHCP client for the given interface.
func NewClient(ctx context.Context, ifaces []netlink.Link, c *Config, l *zerolog.Logger) (*Client, error) {
scheduler, err := gocron.NewScheduler()
if err != nil {
return nil, fmt.Errorf("failed to create scheduler: %w", err)
}
cfg := *c
if cfg.LinkUpTimeout == 0 {
cfg.LinkUpTimeout = 30 * time.Second
}
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
if cfg.Retries == 0 {
cfg.Retries = 3
}
return &Client{
ctx: ctx,
ifaces: ifaces,
cfg: cfg,
l: l,
scheduler: scheduler,
currentLease4: nil,
currentLease6: nil,
lease4Mu: sync.Mutex{},
lease6Mu: sync.Mutex{},
mu: sync.Mutex{},
cfgMu: sync.Mutex{},
}, nil
}
func (c *Client) ensureInterfaceUp(iface netlink.Link) (netlink.Link, error) {
ifname := iface.Attrs().Name
l := c.l.With().Str("interface", ifname).Logger()
linkUpTimeout := time.After(c.cfg.LinkUpTimeout)
for {
link, err := netlink.LinkByName(ifname)
if err != nil {
return nil, err
}
state := link.Attrs().OperState
if state == netlink.OperUp || state == netlink.OperUnknown {
return link, nil
}
l.Info().Interface("state", state).Msg("bringing up interface")
if err = netlink.LinkSetUp(link); err != nil {
l.Error().Err(err).Msg("interface can't make it up")
}
select {
case <-time.After(100 * time.Millisecond):
continue
case <-c.ctx.Done():
if err != nil {
return nil, err
}
return nil, ErrInterfaceUpCanceled
case <-linkUpTimeout:
l.Error().Msg("interface is still down after timeout")
if err != nil {
return nil, err
}
return nil, ErrInterfaceUpTimeout
}
}
}
func (c *Client) sendInitialRequests() chan interface{} {
return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6)
}
func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} {
c.mu.Lock()
defer c.mu.Unlock()
// Yeah, this is a hack, until we can cancel all leases in progress.
r := make(chan interface{}, 3*len(c.ifaces))
var wg sync.WaitGroup
for _, iface := range c.ifaces {
wg.Add(1)
go func(iface netlink.Link) {
defer wg.Done()
ifname := iface.Attrs().Name
l := c.l.With().Str("interface", ifname).Logger()
iface, err := c.ensureInterfaceUp(iface)
if err != nil {
l.Error().Err(err).Msg("Could not bring up interface")
return
}
if ipv4 {
wg.Add(1)
go func(iface netlink.Link) {
defer wg.Done()
lease, err := c.requestLease4(iface)
if err != nil {
l.Error().Err(err).Msg("Could not get IPv4 lease")
return
}
r <- lease
}(iface)
}
if ipv6 {
return // TODO: implement DHCP6
wg.Add(1)
go func(iface netlink.Link) {
defer wg.Done()
lease, err := c.requestLease6(iface)
if err != nil {
l.Error().Err(err).Msg("Could not get IPv6 lease")
return
}
r <- lease
}(iface)
}
}(iface)
}
go func() {
wg.Wait()
close(r)
}()
return r
}
func (c *Client) Lease4() *Lease {
c.lease4Mu.Lock()
defer c.lease4Mu.Unlock()
return c.currentLease4
}
func (c *Client) Lease6() *Lease {
c.lease6Mu.Lock()
defer c.lease6Mu.Unlock()
return c.currentLease6
}
func (c *Client) Domain() string {
c.lease4Mu.Lock()
defer c.lease4Mu.Unlock()
if c.currentLease4 != nil {
return c.currentLease4.Domain
}
c.lease6Mu.Lock()
defer c.lease6Mu.Unlock()
if c.currentLease6 != nil {
return c.currentLease6.Domain
}
return ""
}
func (c *Client) handleLeaseChange(lease *Lease) {
// do not use defer here, because we need to unlock the mutex before returning
ipv4 := lease.p4 != nil
version := "ipv4"
if ipv4 {
c.lease4Mu.Lock()
c.currentLease4 = lease
} else {
version = "ipv6"
c.lease6Mu.Lock()
c.currentLease6 = lease
}
// clear all current jobs with the same tags
c.scheduler.RemoveByTags(version)
// add scheduler job to renew the lease
if lease.RenewalTime > 0 {
c.scheduler.NewJob(
gocron.DurationJob(lease.RenewalTime),
gocron.NewTask(func() {
c.l.Info().Msg("renewing lease")
for lease := range c.sendRequests(ipv4, !ipv4) {
if lease, ok := lease.(*Lease); ok {
c.handleLeaseChange(lease)
}
}
}),
gocron.WithName(fmt.Sprintf("renew-%s", version)),
gocron.WithSingletonMode(gocron.LimitModeWait),
gocron.WithTags(version),
)
}
c.apply()
if ipv4 {
c.lease4Mu.Unlock()
} else {
c.lease6Mu.Unlock()
}
// TODO: handle lease expiration
if c.cfg.OnLease4Change != nil && ipv4 {
c.cfg.OnLease4Change(lease)
}
if c.cfg.OnLease6Change != nil && !ipv4 {
c.cfg.OnLease6Change(lease)
}
}
func (c *Client) renew() {
for lease := range c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) {
if lease, ok := lease.(*Lease); ok {
c.handleLeaseChange(lease)
}
}
}
func (c *Client) Renew() {
go c.renew()
}
func (c *Client) Release() {
// TODO: implement
}
func (c *Client) SetIPv4(ipv4 bool) {
c.cfgMu.Lock()
defer c.cfgMu.Unlock()
currentIPv4 := c.cfg.IPv4
c.cfg.IPv4 = ipv4
if !ipv4 {
c.lease4Mu.Lock()
c.currentLease4 = nil
c.lease4Mu.Unlock()
c.scheduler.RemoveByTags("ipv4")
}
if currentIPv4 || ipv4 {
// TODO: send initial requests
}
}
func (c *Client) SetIPv6(ipv6 bool) {
c.cfg.IPv6 = ipv6
}
func (c *Client) Start() error {
if err := c.killUdhcpc(); err != nil {
c.l.Warn().Err(err).Msg("failed to kill udhcpc processes, continuing anyway")
}
c.scheduler.Start()
go func() {
for lease := range c.sendInitialRequests() {
if lease, ok := lease.(*Lease); ok {
c.handleLeaseChange(lease)
}
}
}()
return nil
}
func (c *Client) apply() {
var (
iface string
nameservers []net.IP
searchList []string
domain string
)
if c.currentLease4 != nil {
iface = c.currentLease4.InterfaceName
nameservers = c.currentLease4.DNS
searchList = c.currentLease4.SearchList
domain = c.currentLease4.Domain
}
if c.currentLease6 != nil {
iface = c.currentLease6.InterfaceName
nameservers = append(nameservers, c.currentLease6.DNS...)
searchList = append(searchList, c.currentLease6.SearchList...)
domain = c.currentLease6.Domain
}
// deduplicate searchList
searchList = slices.Compact(searchList)
c.l.Info().
Str("interface", iface).
Interface("nameservers", nameservers).
Interface("searchList", searchList).
Str("domain", domain).
Msg("updating resolv.conf")
if err := updateResolvConf(iface, nameservers, searchList, domain); err != nil {
c.l.Error().Err(err).Msg("failed to update resolv.conf")
}
}

View File

@ -0,0 +1,52 @@
package dhclient
import (
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/vishvananda/netlink"
)
func (c *Client) requestLease4(iface netlink.Link) (*Lease, error) {
ifname := iface.Attrs().Name
l := c.l.With().Str("interface", ifname).Logger()
mods := []nclient4.ClientOpt{
nclient4.WithTimeout(c.cfg.Timeout),
nclient4.WithRetry(c.cfg.Retries),
}
mods = append(mods, c.getDHCP4Logger(ifname))
if c.cfg.V4ServerAddr != nil {
mods = append(mods, nclient4.WithServerAddr(c.cfg.V4ServerAddr))
}
client, err := nclient4.New(ifname, mods...)
if err != nil {
return nil, err
}
defer client.Close()
// Prepend modifiers with default options, so they can be overriden.
reqmods := append(
[]dhcpv4.Modifier{
dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)),
dhcpv4.WithRequestedOptions(dhcpv4.OptionSubnetMask),
},
c.cfg.Modifiers4...)
if c.cfg.V4ClientIdentifier {
// Client Id is hardware type + mac per RFC 2132 9.14.
ident := []byte{0x01} // Type ethernet
ident = append(ident, iface.Attrs().HardwareAddr...)
reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier(ident)))
}
l.Info().Msg("attempting to get DHCPv4 lease")
lease, err := client.Request(c.ctx, reqmods...)
if err != nil {
return nil, err
}
l.Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.Summary())
return fromNclient4Lease(lease, ifname), nil
}

132
internal/dhclient/dhcp6.go Normal file
View File

@ -0,0 +1,132 @@
package dhclient
import (
"log"
"net"
"time"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
)
// isIPv6LinkReady returns true if the interface has a link-local address
// which is not tentative.
func isIPv6LinkReady(l netlink.Link) (bool, error) {
addrs, err := netlink.AddrList(l, netlink.FAMILY_V6)
if err != nil {
return false, err
}
for _, addr := range addrs {
if addr.IP.IsLinkLocalUnicast() && (addr.Flags&unix.IFA_F_TENTATIVE == 0) {
if addr.Flags&unix.IFA_F_DADFAILED != 0 {
log.Printf("DADFAILED for %v, continuing anyhow", addr.IP)
}
return true, nil
}
}
return false, nil
}
// isIPv6RouteReady returns true if serverAddr is reachable.
func isIPv6RouteReady(serverAddr net.IP) waitForCondition {
return func(l netlink.Link) (bool, error) {
if serverAddr.IsMulticast() {
return true, nil
}
routes, err := netlink.RouteList(l, netlink.FAMILY_V6)
if err != nil {
return false, err
}
for _, route := range routes {
if route.LinkIndex != l.Attrs().Index {
continue
}
// Default route.
if route.Dst == nil {
return true, nil
}
if route.Dst.Contains(serverAddr) {
return true, nil
}
}
return false, nil
}
}
func (c *Client) requestLease6(iface netlink.Link) (*Lease, error) {
ifname := iface.Attrs().Name
l := c.l.With().Str("interface", ifname).Logger()
clientPort := dhcpv6.DefaultClientPort
if c.cfg.V6ClientPort != nil {
clientPort = *c.cfg.V6ClientPort
}
// For ipv6, we cannot bind to the port until Duplicate Address
// Detection (DAD) is complete which is indicated by the link being no
// longer marked as "tentative". This usually takes about a second.
// If the link is never going to be ready, don't wait forever.
// (The user may not have configured a ctx with a timeout.)
linkUpTimeout := time.After(c.cfg.LinkUpTimeout)
if err := c.waitFor(
iface,
linkUpTimeout,
isIPv6LinkReady,
ErrIPv6LinkTimeout,
); err != nil {
return nil, err
}
// If user specified a non-multicast address, make sure it's routable before we start.
if c.cfg.V6ServerAddr != nil {
if err := c.waitFor(
iface,
linkUpTimeout,
isIPv6RouteReady(c.cfg.V6ServerAddr.IP),
ErrIPv6RouteTimeout,
); err != nil {
return nil, err
}
}
mods := []nclient6.ClientOpt{
nclient6.WithTimeout(c.cfg.Timeout),
nclient6.WithRetry(c.cfg.Retries),
c.getDHCP6Logger(),
}
if c.cfg.V6ServerAddr != nil {
mods = append(mods, nclient6.WithBroadcastAddr(c.cfg.V6ServerAddr))
}
conn, err := nclient6.NewIPv6UDPConn(iface.Attrs().Name, clientPort)
if err != nil {
return nil, err
}
client, err := nclient6.NewWithConn(conn, iface.Attrs().HardwareAddr, mods...)
if err != nil {
return nil, err
}
defer client.Close()
// Prepend modifiers with default options, so they can be overriden.
reqmods := append(
[]dhcpv6.Modifier{
dhcpv6.WithNetboot,
},
c.cfg.Modifiers6...)
l.Info().Msg("attempting to get DHCPv6 lease")
p, err := client.RapidSolicit(c.ctx, reqmods...)
if err != nil {
return nil, err
}
l.Info().Msgf("DHCPv6 lease acquired: %s", p.Summary())
return fromNclient6Lease(p, ifname), nil
}

View File

@ -1,4 +1,4 @@
package udhcpc package dhclient
import ( import (
"bufio" "bufio"
@ -10,8 +10,17 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6"
) )
var (
defaultLeaseTime = time.Duration(30 * time.Minute)
defaultRenewalTime = time.Duration(15 * time.Minute)
)
// Lease is a network configuration obtained by DHCP.
type Lease struct { type Lease struct {
// from https://udhcp.busybox.net/README.udhcpc // from https://udhcp.busybox.net/README.udhcpc
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
@ -21,6 +30,7 @@ type Lease struct {
MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network
HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname
Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network
SearchList []string `env:"search" json:"search_list,omitempty"` // The search list for the network
BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option
BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option
BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option
@ -38,24 +48,100 @@ type Lease struct {
BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile
RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk
LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds
RenewalTime time.Duration `env:"renewal" json:"renewal,omitempty"` // The renewal time, in seconds
RebindingTime time.Duration `env:"rebinding" json:"rebinding,omitempty"` // The rebinding time, in seconds
DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored) DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored)
ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server
Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK
TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name
BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name
Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds
ClassIdentifier string `env:"classid" json:"class_identifier,omitempty"` // The class identifier
LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease
InterfaceName string `json:"interface_name,omitempty"` // The name of the interface
p4 *nclient4.Lease
p6 *dhcpv6.Message
isEmpty map[string]bool isEmpty map[string]bool
} }
// fromNclient4Lease creates a lease from a nclient4.Lease.
func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease {
lease := &Lease{}
lease.p4 = l
// only the fields that we need are set
lease.Routers = l.ACK.Router()
lease.IPAddress = l.ACK.YourIPAddr
lease.Netmask = net.IP(l.ACK.SubnetMask())
lease.Broadcast = l.ACK.BroadcastAddress()
// lease.MTU = int(resp.Options.Get(dhcpv4.OptionInterfaceMTU))
lease.NTPServers = l.ACK.NTPServers()
lease.HostName = l.ACK.HostName()
lease.Domain = l.ACK.DomainName()
searchList := l.ACK.DomainSearch()
if searchList != nil {
lease.SearchList = searchList.Labels
}
lease.DNS = l.ACK.DNS()
lease.ClassIdentifier = l.ACK.ClassIdentifier()
lease.ServerID = l.ACK.ServerIdentifier().String()
lease.Message = l.ACK.Message()
lease.LeaseTime = l.ACK.IPAddressLeaseTime(defaultLeaseTime)
lease.RenewalTime = l.ACK.IPAddressRenewalTime(defaultRenewalTime)
lease.InterfaceName = iface
return lease
}
// fromNclient6Lease creates a lease from a nclient6.Message.
func fromNclient6Lease(l *dhcpv6.Message, iface string) *Lease {
lease := &Lease{}
lease.p6 = l
iana := l.Options.OneIANA()
if iana == nil {
return nil
}
address := iana.Options.OneAddress()
if address == nil {
return nil
}
lease.IPAddress = address.IPv6Addr
lease.Netmask = net.IP(net.CIDRMask(128, 128))
lease.DNS = l.Options.DNS()
// lease.LeaseTime = iana.Options.OnePreferredLifetime()
// lease.RenewalTime = iana.Options.OneValidLifetime()
// lease.RebindingTime = iana.Options.OneRebindingTime()
lease.InterfaceName = iface
return lease
}
func (l *Lease) setIsEmpty(m map[string]bool) { func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m l.isEmpty = m
} }
// IsEmpty returns true if the lease is empty for the given key.
func (l *Lease) IsEmpty(key string) bool { func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key] return l.isEmpty[key]
} }
// ToJSON returns the lease as a JSON string.
func (l *Lease) ToJSON() string { func (l *Lease) ToJSON() string {
json, err := json.Marshal(l) json, err := json.Marshal(l)
if err != nil { if err != nil {
@ -64,13 +150,13 @@ func (l *Lease) ToJSON() string {
return string(json) return string(json)
} }
// SetLeaseExpiry sets the lease expiry time.
func (l *Lease) SetLeaseExpiry() (time.Time, error) { func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 { if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set") return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
} }
// get the uptime of the device // get the uptime of the device
file, err := os.Open("/proc/uptime") file, err := os.Open("/proc/uptime")
if err != nil { if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err) return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
@ -98,6 +184,7 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) {
return leaseExpiry, nil return leaseExpiry, nil
} }
// UnmarshalDHCPCLease unmarshals a lease from a string.
func UnmarshalDHCPCLease(lease *Lease, str string) error { func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map // parse the lease file as a map
data := make(map[string]string) data := make(map[string]string)
@ -184,3 +271,45 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error {
return nil return nil
} }
// MarshalDHCPCLease marshals a lease to a string.
func MarshalDHCPCLease(lease *Lease) (string, error) {
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
leaseFile := ""
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
outValue := ""
switch field.Interface().(type) {
case string:
outValue = field.String()
case int:
outValue = strconv.Itoa(int(field.Int()))
case time.Duration:
outValue = strconv.Itoa(int(field.Int()))
case net.IP:
outValue = field.String()
case []net.IP:
ips := field.Interface().([]net.IP)
ipStrings := make([]string, len(ips))
for i, ip := range ips {
ipStrings[i] = ip.String()
}
outValue = strings.Join(ipStrings, " ")
default:
return "", fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
leaseFile += fmt.Sprintf("%s=%s\n", key, outValue)
}
return leaseFile, nil
}

View File

@ -0,0 +1,95 @@
package dhclient
import (
"bytes"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
func readFileNoStat(filename string) ([]byte, error) {
const maxBufferSize = 1024 * 1024
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
reader := io.LimitReader(f, maxBufferSize)
return io.ReadAll(reader)
}
func toCmdline(path string) ([]string, error) {
data, err := readFileNoStat(path)
if err != nil {
return nil, err
}
if len(data) < 1 {
return []string{}, nil
}
return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil
}
func (c *Client) killUdhcpc() error {
// read procfs for udhcpc processes
// we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs
processes, err := os.ReadDir("/proc")
if err != nil {
return err
}
matchedPids := make([]int, 0)
// iterate over the processes
for _, d := range processes {
// check if file is numeric
pid, err := strconv.Atoi(d.Name())
if err != nil {
continue
}
// check if it's a directory
if !d.IsDir() {
continue
}
cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline"))
if err != nil {
continue
}
if len(cmdline) < 1 {
continue
}
if cmdline[0] != "udhcpc" {
continue
}
matchedPids = append(matchedPids, pid)
}
if len(matchedPids) == 0 {
c.l.Info().Msg("no udhcpc processes found")
return nil
}
c.l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating")
for _, pid := range matchedPids {
err := syscall.Kill(pid, syscall.SIGTERM)
if err != nil {
return err
}
c.l.Info().Int("pid", pid).Msg("terminated udhcpc process")
}
return nil
}

View File

@ -0,0 +1,40 @@
package dhclient
import (
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
"github.com/rs/zerolog"
)
type dhcpLogger struct {
// Printfer is used for actual output of the logger
nclient4.Printfer
l *zerolog.Logger
}
// Printf prints a log message as-is via predefined Printfer
func (s dhcpLogger) Printf(format string, v ...interface{}) {
s.l.Info().Msgf(format, v...)
}
// PrintMessage prints a DHCP message in the short format via predefined Printfer
func (s dhcpLogger) PrintMessage(prefix string, message *dhcpv4.DHCPv4) {
s.l.Info().Str("prefix", prefix).Str("message", message.String()).Msg("DHCP message")
}
func (c *Client) getDHCP4Logger(ifname string) nclient4.ClientOpt {
logger := c.l.With().Str("interface", ifname).Logger()
return nclient4.WithLogger(dhcpLogger{
l: &logger,
})
}
// TODO: nclient6 doesn't implement the WithLogger option,
// we might need to open a PR to add it
func (c *Client) getDHCP6Logger() nclient6.ClientOpt {
return nclient6.WithSummaryLogger()
}

View File

@ -0,0 +1,62 @@
package dhclient
import (
"bytes"
"fmt"
"html/template"
"net"
"os"
"strings"
)
const (
resolvConfPath = "/etc/resolv.conf"
resolvConfFileMode = 0644
resolvConfTemplate = `# the resolv.conf file is managed by the jetkvm-dhclient service
# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN
{{ if .searchList }}
search {{ join .searchList " " }} # {{ .iface }}
{{- end -}}
{{ if .domain }}
domain {{ .domain }} # {{ .iface }}
{{- end -}}
{{ range .nameservers }}
nameserver {{ printf "%s" . }} # {{ $.iface }}
{{- end }}
`
)
var (
tplFuncMap = template.FuncMap{
"join": strings.Join,
}
)
func toResolvConf(iface string, nameservers []net.IP, searchList []string, domain string) (bytes.Buffer, error) {
rc := bytes.Buffer{}
tmpl, err := template.New("resolv.conf").Funcs(tplFuncMap).Parse(resolvConfTemplate)
if err != nil {
return rc, fmt.Errorf("failed to parse template: %w", err)
}
if err := tmpl.Execute(&rc, map[string]interface{}{
"iface": iface,
"nameservers": nameservers,
"searchList": searchList,
"domain": domain,
}); err != nil {
return rc, fmt.Errorf("failed to execute template: %w", err)
}
return rc, nil
}
func updateResolvConf(iface string, nameservers []net.IP, searchList []string, domain string) error {
rc, err := toResolvConf(iface, nameservers, searchList, domain)
if err != nil {
return err
}
return os.WriteFile(resolvConfPath, rc.Bytes(), resolvConfFileMode)
}

View File

@ -0,0 +1,35 @@
package dhclient
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
)
func TestToResolvConf(t *testing.T) {
rc, err := toResolvConf(
"eth0",
[]net.IP{
net.ParseIP("198.51.100.53"),
net.ParseIP("203.0.113.53"),
},
[]string{"example.com"},
"example.com",
)
if err != nil {
t.Fatal(err)
}
want := `# the resolv.conf file is managed by the jetkvm-dhclient service
# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN
search example.com # eth0
domain example.com # eth0
nameserver 198.51.100.53 # eth0
nameserver 203.0.113.53 # eth0
`
assert.Equal(t, want, rc.String())
}

View File

@ -0,0 +1,46 @@
package dhclient
import (
"context"
"time"
"github.com/vishvananda/netlink"
)
type waitForCondition func(l netlink.Link) (ready bool, err error)
func (c *Client) waitFor(
link netlink.Link,
timeout <-chan time.Time,
condition waitForCondition,
timeoutError error,
) error {
return waitFor(c.ctx, link, timeout, condition, timeoutError)
}
func waitFor(
ctx context.Context,
link netlink.Link,
timeout <-chan time.Time,
condition waitForCondition,
timeoutError error,
) error {
for {
if ready, err := condition(link); err != nil {
return err
} else if ready {
break
}
select {
case <-time.After(100 * time.Millisecond):
continue
case <-timeout:
return timeoutError
case <-ctx.Done():
return timeoutError
}
}
return nil
}

View File

@ -28,7 +28,8 @@ type IPv4StaticConfig struct {
} }
type IPv6StaticConfig struct { type IPv6StaticConfig struct {
Address null.String `json:"address,omitempty" validate_type:"cidr" required:"true"` Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"`
PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" required:"true"`
Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"` Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
} }
@ -108,7 +109,7 @@ func (s *NetworkInterfaceState) GetDomain() string {
domain := ToValidDomain(s.config.Domain.String) domain := ToValidDomain(s.config.Domain.String)
if domain == "" { if domain == "" {
lease := s.dhcpClient.GetLease() lease := s.dhcpClient.Lease4()
if lease != nil && lease.Domain != "" { if lease != nil && lease.Domain != "" {
domain = ToValidDomain(lease.Domain) domain = ToValidDomain(lease.Domain)
} }

View File

@ -1,13 +1,14 @@
package network package network
import ( import (
"context"
"fmt" "fmt"
"net" "net"
"sync" "sync"
"github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/dhclient"
"github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/udhcpc"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/vishvananda/netlink" "github.com/vishvananda/netlink"
@ -28,7 +29,8 @@ type NetworkInterfaceState struct {
stateLock sync.Mutex stateLock sync.Mutex
config *NetworkConfig config *NetworkConfig
dhcpClient *udhcpc.DHCPClient ifConfig *NetworkInterfaceConfig
dhcpClient *dhclient.Client
defaultHostname string defaultHostname string
currentHostname string currentHostname string
@ -48,7 +50,7 @@ type NetworkInterfaceOptions struct {
DefaultHostname string DefaultHostname string
OnStateChange func(state *NetworkInterfaceState) OnStateChange func(state *NetworkInterfaceState)
OnInitialCheck func(state *NetworkInterfaceState) OnInitialCheck func(state *NetworkInterfaceState)
OnDhcpLeaseChange func(lease *udhcpc.Lease) OnDhcpLeaseChange func(lease *dhclient.Lease)
OnConfigChange func(config *NetworkConfig) OnConfigChange func(config *NetworkConfig)
NetworkConfig *NetworkConfig NetworkConfig *NetworkConfig
} }
@ -80,12 +82,23 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
ntpAddresses: make([]*net.IP, 0), ntpAddresses: make([]*net.IP, 0),
} }
ifConfig, err := NewNetworkInterfaceConfig(opts.InterfaceName, opts.NetworkConfig, opts.Logger)
if err != nil {
return nil, err
}
link, err := netlink.LinkByName(opts.InterfaceName)
if err != nil {
return nil, err
}
ctx := context.Background()
// create the dhcp client // create the dhcp client
dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ dhcpClient, err := dhclient.NewClient(ctx, []netlink.Link{link}, &dhclient.Config{
InterfaceName: opts.InterfaceName, IPv4: true,
PidFile: opts.DhcpPidFile, IPv6: true,
Logger: l, OnLease4Change: func(lease *dhclient.Lease) {
OnLeaseChange: func(lease *udhcpc.Lease) {
_, err := s.update() _, err := s.update()
if err != nil { if err != nil {
opts.Logger.Error().Err(err).Msg("failed to update network state") opts.Logger.Error().Err(err).Msg("failed to update network state")
@ -96,9 +109,16 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
opts.OnDhcpLeaseChange(lease) opts.OnDhcpLeaseChange(lease)
}, },
}) OnLease6Change: func(lease *dhclient.Lease) {
// NOT IMPLEMENTED
},
}, l)
if err != nil {
return nil, err
}
s.dhcpClient = dhcpClient s.dhcpClient = dhcpClient
s.ifConfig = ifConfig
return s, nil return s, nil
} }
@ -341,7 +361,7 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
return dhcpTargetState, nil return dhcpTargetState, nil
} }
func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error { func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *dhclient.Lease) error {
if lease != nil && len(lease.NTPServers) > 0 { if lease != nil && len(lease.NTPServers) > 0 {
s.l.Info().Msg("lease found, updating DHCP NTP addresses") s.l.Info().Msg("lease found, updating DHCP NTP addresses")
s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers)) s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers))
@ -369,10 +389,10 @@ func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
switch dhcpTargetState { switch dhcpTargetState {
case DhcpTargetStateRenew: case DhcpTargetStateRenew:
s.l.Info().Msg("renewing DHCP lease") s.l.Info().Msg("renewing DHCP lease")
_ = s.dhcpClient.Renew() s.dhcpClient.Renew()
case DhcpTargetStateRelease: case DhcpTargetStateRelease:
s.l.Info().Msg("releasing DHCP lease") s.l.Info().Msg("releasing DHCP lease")
_ = s.dhcpClient.Release() s.dhcpClient.Release()
case DhcpTargetStateStart: case DhcpTargetStateStart:
s.l.Warn().Msg("dhcpTargetStateStart not implemented") s.l.Warn().Msg("dhcpTargetStateStart not implemented")
case DhcpTargetStateStop: case DhcpTargetStateStop:
@ -384,5 +404,10 @@ func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) { func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) {
_ = s.setHostnameIfNotSame() _ = s.setHostnameIfNotSame()
if err := s.ifConfig.Apply(s); err != nil {
s.l.Error().Err(err).Msg("failed to apply network interface config")
}
s.cbConfigChange(config) s.cbConfigChange(config)
} }

View File

@ -28,12 +28,18 @@ func (s *NetworkInterfaceState) Run() error {
_ = s.setHostnameIfNotSame() _ = s.setHostnameIfNotSame()
// run the dhcp client // run the dhcp client
go s.dhcpClient.Run() // nolint:errcheck if err := s.dhcpClient.Start(); err != nil {
return err
}
if err := s.CheckAndUpdateDhcp(); err != nil { if err := s.CheckAndUpdateDhcp(); err != nil {
return err return err
} }
if err := s.ifConfig.Apply(s); err != nil {
s.l.Error().Err(err).Msg("failed to apply network interface config")
}
go func() { go func() {
ticker := time.NewTicker(1 * time.Second) ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop() defer ticker.Stop()

View File

@ -5,7 +5,7 @@ import (
"time" "time"
"github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/udhcpc" "github.com/jetkvm/kvm/internal/dhclient"
) )
type RpcIPv6Address struct { type RpcIPv6Address struct {
@ -23,7 +23,8 @@ type RpcNetworkState struct {
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
IPv4Addresses []string `json:"ipv4_addresses,omitempty"` IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"` IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"`
DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` DHCPLease4 *dhclient.Lease `json:"dhcp_lease,omitempty"` // name kept for backwards compatibility
DHCPLease6 *dhclient.Lease `json:"dhcp_lease6,omitempty"`
} }
type RpcNetworkSettings struct { type RpcNetworkSettings struct {
@ -84,7 +85,8 @@ func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
IPv6LinkLocal: s.IPv6LinkLocalAddress(), IPv6LinkLocal: s.IPv6LinkLocalAddress(),
IPv4Addresses: s.ipv4Addresses, IPv4Addresses: s.ipv4Addresses,
IPv6Addresses: ipv6Addresses, IPv6Addresses: ipv6Addresses,
DHCPLease: s.dhcpClient.GetLease(), DHCPLease4: s.dhcpClient.Lease4(),
DHCPLease6: s.dhcpClient.Lease6(),
} }
} }
@ -111,6 +113,14 @@ func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSetting
return nil return nil
} }
if err := validateIPv4Config(&settings.NetworkConfig); err != nil {
return err
}
if err := validateIPv6Config(&settings.NetworkConfig); err != nil {
return err
}
s.config = &settings.NetworkConfig s.config = &settings.NetworkConfig
s.onConfigChange(s.config) s.onConfigChange(s.config)
@ -122,5 +132,7 @@ func (s *NetworkInterfaceState) RpcRenewDHCPLease() error {
return fmt.Errorf("dhcp client not initialized") return fmt.Errorf("dhcp client not initialized")
} }
return s.dhcpClient.Renew() s.dhcpClient.Renew()
return nil
} }

367
internal/network/static.go Normal file
View File

@ -0,0 +1,367 @@
package network
import (
"fmt"
"net"
"os"
"path"
"strconv"
"strings"
"sync"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
)
const defaultInterface = "eth0"
const (
AF_INET = nl.FAMILY_V4
AF_INET6 = nl.FAMILY_V6
sysctlBase = "/proc/sys"
sysctlFileMode = 0640
)
type NetworkInterfaceConfig struct {
config *NetworkConfig
l *zerolog.Logger
ifaceName string
iface *netlink.Link
lock sync.Mutex
ipv4Lock sync.Mutex
ipv6Lock sync.Mutex
}
var (
ipv4DefaultRoute = net.IPNet{
IP: net.IPv4zero,
Mask: net.CIDRMask(0, 0),
}
ipv6DefaultRoute = net.IPNet{
IP: net.IPv6zero,
Mask: net.CIDRMask(0, 0),
}
)
// NewNetworkInterfaceConfig ...
func NewNetworkInterfaceConfig(ifaceName string, config *NetworkConfig, logger *zerolog.Logger) (*NetworkInterfaceConfig, error) {
if ifaceName == "" {
ifaceName = defaultInterface
}
link, err := netlink.LinkByName(ifaceName)
if err != nil {
return nil, fmt.Errorf("failed to get link by name: %w", err)
}
if config == nil {
return nil, fmt.Errorf("config is nil")
}
if logger == nil {
logger = logging.GetSubsystemLogger("network")
}
scopedLogger := logger.With().Str("iface", ifaceName).Logger()
return &NetworkInterfaceConfig{
config: config,
l: &scopedLogger,
ifaceName: ifaceName,
iface: &link,
lock: sync.Mutex{},
ipv4Lock: sync.Mutex{},
ipv6Lock: sync.Mutex{},
}, nil
}
func (c *NetworkInterfaceConfig) Apply(s *NetworkInterfaceState) error {
if err := c.applyIPv4(s); err != nil {
return err
}
if err := c.applyIPv6(s); err != nil {
return err
}
return nil
}
func (c *NetworkInterfaceConfig) applyIPv4(s *NetworkInterfaceState) error {
switch c.config.IPv4Mode.String {
case "static":
return c.applyIPv4Static(s)
case "dhcp":
s.dhcpClient.SetIPv4(true)
return nil
case "disabled":
s.dhcpClient.SetIPv4(false)
return nil
default:
return fmt.Errorf("invalid IPv4 mode: %s", c.config.IPv4Mode.String)
}
}
func (c *NetworkInterfaceConfig) applyIPv6(s *NetworkInterfaceState) error {
switch c.config.IPv6Mode.String {
case "static":
return c.applyIPv6Static(s)
case "dhcpv6":
return fmt.Errorf("not implemented")
case "slaac":
return c.applyIPv6Slaac()
case "slaac_and_dhcpv6":
return fmt.Errorf("not implemented")
case "link_local":
return c.applyIPv6LinkLocalOnly()
case "disabled":
return c.disableIPv6()
default:
return fmt.Errorf("invalid IPv6 mode: %s", c.config.IPv6Mode.String)
}
}
func checkIfAddressIsSetOrReturnCurrent(iface *netlink.Link, address net.IP, family int) (bool, []*netlink.Addr) {
addr, err := netlink.AddrList(*iface, family)
if err != nil {
return false, nil
}
hit := false
linkAddrs := make([]*netlink.Addr, 0)
for _, a := range addr {
if a.IP.Equal(address) {
hit = true
continue
}
// we don't want to delete link-local addresses
if family == AF_INET6 && a.IP.IsLinkLocalUnicast() {
continue
}
linkAddrs = append(linkAddrs, &a)
}
return hit, linkAddrs
}
func reconcileLinkAddrs(iface *netlink.Link, family int, expectedAddr *net.IPNet, logger *zerolog.Logger) error {
// TODO: we need to check if the netmask is the same
hit, currentLinkAddrs := checkIfAddressIsSetOrReturnCurrent(iface, expectedAddr.IP, family)
if !hit {
logger.Info().Interface("ip", expectedAddr).Msg("adding address")
if err := netlink.AddrAdd(*iface, &netlink.Addr{
IPNet: expectedAddr,
}); err != nil {
logger.Info().Interface("ip", expectedAddr).Msg("failed to set address")
return fmt.Errorf("failed to add address: %w", err)
}
}
for _, addr := range currentLinkAddrs {
logger.Info().Interface("ip", addr.IP).Msg("deleting address")
if err := netlink.AddrDel(*iface, addr); err != nil {
return fmt.Errorf("failed to delete address: %w", err)
}
}
return nil
}
func checkIfDefaultRouteIsSet(gateway net.IP, family int) bool {
defaultRoute := ipv4DefaultRoute
if family == AF_INET6 {
defaultRoute = ipv6DefaultRoute
}
routes, err := netlink.RouteListFiltered(family, &netlink.Route{Dst: &defaultRoute}, netlink.RT_FILTER_DST)
if err != nil {
return false
}
for _, r := range routes {
if r.Dst.IP.Equal(defaultRoute.IP) && r.Gw.Equal(gateway) {
return true
}
}
return false
}
func ensureInterfaceIsUp(iface *netlink.Link) error {
if (*iface).Attrs().OperState == netlink.OperUp {
return nil
}
if err := netlink.LinkSetUp(*iface); err != nil {
return fmt.Errorf("failed to set interface up: %w", err)
}
return nil
}
func (c *NetworkInterfaceConfig) applyIPv4Static(s *NetworkInterfaceState) error {
c.ipv4Lock.Lock()
defer c.ipv4Lock.Unlock()
config, err := parseAndValidateStaticIPv4Config(c.config.IPv4Static)
if err != nil {
return err
}
if c.iface == nil {
return fmt.Errorf("interface handle is nil")
}
// disable DHCPv4
s.dhcpClient.SetIPv4(false)
if err := ensureInterfaceIsUp(c.iface); err != nil {
return err
}
if err := reconcileLinkAddrs(c.iface, AF_INET, &config.network, c.l); err != nil {
return err
}
if !checkIfDefaultRouteIsSet(config.gateway, AF_INET) {
c.l.Info().Str("iface", c.ifaceName).Interface("gateway", config.gateway).Msg("adding default route")
// TODO: support point-to-point
if err := netlink.RouteReplace(&netlink.Route{
Dst: &ipv4DefaultRoute,
Gw: config.gateway,
LinkIndex: (*c.iface).Attrs().Index,
}); err != nil {
return fmt.Errorf("failed to add default route: %w", err)
}
}
return ensureInterfaceIsUp(c.iface)
}
func (c *NetworkInterfaceConfig) setSysctlValues(values map[string]int) error {
for name, value := range values {
name = fmt.Sprintf(name, c.ifaceName)
name = strings.ReplaceAll(name, ".", "/")
if err := os.WriteFile(path.Join(sysctlBase, name), []byte(strconv.Itoa(value)), sysctlFileMode); err != nil {
return fmt.Errorf("failed to set sysctl %s=%d: %w", name, value, err)
}
}
return nil
}
func (c *NetworkInterfaceConfig) applyIPv4Dhcp() error {
c.ipv4Lock.Lock()
defer c.ipv4Lock.Unlock()
return nil
}
func (c *NetworkInterfaceConfig) applyIPv4Disabled() error {
c.ipv4Lock.Lock()
defer c.ipv4Lock.Unlock()
addr, err := netlink.AddrList(*c.iface, AF_INET)
if err != nil {
return fmt.Errorf("failed to get address list: %w", err)
}
for _, a := range addr {
netlink.AddrDel(*c.iface, &a)
}
return nil
}
func (c *NetworkInterfaceConfig) applyIPv6Slaac() error {
c.ipv6Lock.Lock()
defer c.ipv6Lock.Unlock()
if err := c.setSysctlValues(map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 0, // enable IPv6
"net.ipv6.conf.%s.accept_ra": 2, // accept even if forwarding is disabled
}); err != nil {
return err
}
return nil
}
func (c *NetworkInterfaceConfig) applyIPv6LinkLocalOnly() error {
c.ipv6Lock.Lock()
defer c.ipv6Lock.Unlock()
if err := c.setSysctlValues(map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 0, // enable IPv6
"net.ipv6.conf.%s.accept_ra": 0, // disable RA
}); err != nil {
return err
}
addr, err := netlink.AddrList(*c.iface, AF_INET6)
if err != nil {
return fmt.Errorf("failed to get address list: %w", err)
}
for _, a := range addr {
if !a.IP.IsLinkLocalUnicast() {
netlink.AddrDel(*c.iface, &a)
}
}
return ensureInterfaceIsUp(c.iface)
}
func (c *NetworkInterfaceConfig) applyIPv6Static(s *NetworkInterfaceState) error {
c.ipv6Lock.Lock()
defer c.ipv6Lock.Unlock()
config, err := parseAndValidateStaticIPv6Config(c.config.IPv6Static)
if err != nil {
return err
}
if c.iface == nil {
return fmt.Errorf("interface handle is nil")
}
if err := c.setSysctlValues(map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 0, // enable IPv6
"net.ipv6.conf.%s.accept_ra": 2, // accept even if forwarding is disabled
}); err != nil {
return err
}
// disable DHCPv6
s.dhcpClient.SetIPv6(false)
if err := reconcileLinkAddrs(c.iface, AF_INET6, &config.prefix, c.l); err != nil {
return err
}
if !checkIfDefaultRouteIsSet(config.gateway, AF_INET6) {
if err := netlink.RouteReplace(&netlink.Route{
Dst: &ipv6DefaultRoute,
Gw: config.gateway,
LinkIndex: (*c.iface).Attrs().Index,
}); err != nil {
return fmt.Errorf("failed to add default route: %w", err)
}
}
return ensureInterfaceIsUp(c.iface)
}
func (c *NetworkInterfaceConfig) disableIPv6() error {
return c.setSysctlValues(map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 1, // disable IPv6
})
}

View File

@ -0,0 +1,26 @@
package network
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestApplyIPv4Static(t *testing.T) {
assert.NoError(t, os.Setenv("JETKVM_DEBUG", "1"))
// conf := &NetworkConfig{
// IPv4Mode: null.StringFrom("dhcp"),
// // IPv4Static: &IPv4StaticConfig{
// // Address: null.StringFrom("203.0.113.100"),
// // Netmask: null.StringFrom("255.255.255.0"),
// // Gateway: null.StringFrom("203.0.113.1"),
// // },
// IPv6Mode: null.StringFrom("disabled"),
// }
// ifc, err := NewNetworkInterfaceConfig("eth0", conf, nil)
// assert.NoError(t, err)
// assert.NoError(t, ifc.Apply())
}

View File

@ -0,0 +1,187 @@
package network
import (
"bytes"
"fmt"
"net"
)
var (
netMask32 = net.IPv4Mask(255, 255, 255, 255)
)
func parseIP(ip string, family int) (net.IP, error) {
addr := net.ParseIP(ip)
if addr == nil {
return nil, fmt.Errorf("failed to parse IP: %s", ip)
}
switch family {
case AF_INET:
ia := addr.To4()
if ia == nil {
return nil, fmt.Errorf("address is not a valid IPv4 address")
}
return ia, nil
case AF_INET6:
ia := addr.To16()
if ia == nil {
return nil, fmt.Errorf("address is not a valid IPv6 address")
}
return ia, nil
default:
return nil, fmt.Errorf("invalid family: %d", family)
}
}
func parseAndValidateUnicastIP(ip string, family int) (net.IP, error) {
addr, err := parseIP(ip, family)
if err != nil {
return nil, err
}
if !addr.IsGlobalUnicast() {
return nil, fmt.Errorf("address is not a global unicast address")
}
return addr, nil
}
func isValidNetMask(s string) bool {
ip := net.ParseIP(s)
if ip == nil {
return false
}
var m net.IPMask
if v4 := ip.To4(); v4 != nil {
m = net.IPMask(v4) // 4 bytes
} else {
v6 := ip.To16()
if v6 == nil {
return false
}
m = net.IPMask(v6) // 16 bytes
}
ones, bits := m.Size()
// Non-contiguous masks return (0,0). /0 is valid and returns (0, 32|128).
return !(ones == 0 && bits == 0)
}
type parsedIPv4Config struct {
address net.IP
netmask net.IPMask
network net.IPNet
gateway net.IP
}
type parsedIPv6Config struct {
address net.IP
prefix net.IPNet
gateway net.IP
}
func parseAndValidateStaticIPv4Config(config *IPv4StaticConfig) (*parsedIPv4Config, error) {
addr, err := parseAndValidateUnicastIP(config.Address.String, AF_INET)
if err != nil {
return nil, fmt.Errorf("failed to parse address: %w", err)
}
netmask, err := parseIP(config.Netmask.String, AF_INET)
if err != nil {
return nil, fmt.Errorf("failed to parse netmask: %w", err)
}
if !isValidNetMask(config.Netmask.String) {
return nil, fmt.Errorf("netmask is not a valid netmask")
}
gateway, err := parseAndValidateUnicastIP(config.Gateway.String, AF_INET)
if err != nil {
return nil, fmt.Errorf("failed to parse gateway: %w", err)
}
if addr.Equal(gateway) {
return nil, fmt.Errorf("address and gateway cannot be the same")
}
netMask := net.IPMask(netmask)
ipNet := net.IPNet{
IP: addr,
Mask: netMask,
}
if !ipNet.Contains(gateway) && !bytes.Equal(ipNet.Mask, netMask32) {
return nil, fmt.Errorf("address is not in the same subnet as the gateway")
}
return &parsedIPv4Config{
address: addr,
netmask: netMask,
network: ipNet,
gateway: gateway,
}, nil
}
func parseAndValidateStaticIPv6Config(config *IPv6StaticConfig) (*parsedIPv6Config, error) {
addr, prefix, err := net.ParseCIDR(fmt.Sprintf("%s/%d", config.Address.String, config.PrefixLength.Int64))
if err != nil {
return nil, fmt.Errorf("failed to parse prefix: %w", err)
}
if !addr.IsGlobalUnicast() {
return nil, fmt.Errorf("address is not a global unicast address")
}
gateway, err := parseIP(config.Gateway.String, AF_INET6)
if err != nil {
return nil, fmt.Errorf("failed to parse gateway: %w", err)
}
if addr.Equal(gateway) {
return nil, fmt.Errorf("address and gateway cannot be the same")
}
if !prefix.Contains(gateway) && !gateway.IsLinkLocalUnicast() {
return nil, fmt.Errorf("gateway is not in the same subnet as the address or is a link-local address")
}
return &parsedIPv6Config{
address: addr,
prefix: *prefix,
gateway: gateway,
}, nil
}
func validateIPv4Config(config *NetworkConfig) error {
switch config.IPv4Mode.String {
case "static":
_, err := parseAndValidateStaticIPv4Config(config.IPv4Static)
return err
case "dhcp":
return nil
case "disabled":
return nil
default:
return fmt.Errorf("invalid IPv4 mode: %s", config.IPv4Mode.String)
}
}
func validateIPv6Config(config *NetworkConfig) error {
switch config.IPv6Mode.String {
case "static":
_, err := parseAndValidateStaticIPv6Config(config.IPv6Static)
return err
case "slaac":
return nil
case "dhcpv6":
return fmt.Errorf("not implemented")
case "slaac_and_dhcpv6":
return fmt.Errorf("not implemented")
case "link_local":
return nil
case "disabled":
return nil
default:
return fmt.Errorf("invalid IPv6 mode: %s", config.IPv6Mode.String)
}
}

View File

@ -0,0 +1,151 @@
package network
import (
"testing"
"github.com/guregu/null/v6"
"github.com/stretchr/testify/assert"
)
func TestValidateInvalidIpv4Netmask(t *testing.T) {
_, err := parseAndValidateStaticIPv4Config(
&IPv4StaticConfig{
Address: null.StringFrom("192.168.1.100"),
Netmask: null.StringFrom("123.45.67.89"),
Gateway: null.StringFrom("192.168.1.1"),
},
)
assert.Error(t, err, "expected error, got nil")
}
func TestValidateInvalidIpv4Address(t *testing.T) {
_, err := parseAndValidateStaticIPv4Config(
&IPv4StaticConfig{
Address: null.StringFrom("192.168.1.666"),
Netmask: null.StringFrom("255.255.255.0"),
Gateway: null.StringFrom("192.168.1.1"),
},
)
assert.Error(t, err, "expected error, got nil")
}
func TestValidateIpv4GatewaySameAsAddress(t *testing.T) {
_, err := parseAndValidateStaticIPv4Config(
&IPv4StaticConfig{
Address: null.StringFrom("192.168.1.1"),
Netmask: null.StringFrom("255.255.255.0"),
Gateway: null.StringFrom("192.168.1.1"),
},
)
assert.Error(t, err, "expected error, got nil")
}
func TestValidateIpv4GatewayNotInNetmask(t *testing.T) {
_, err := parseAndValidateStaticIPv4Config(
&IPv4StaticConfig{
Address: null.StringFrom("192.168.1.100"),
Netmask: null.StringFrom("255.255.255.0"),
Gateway: null.StringFrom("192.168.2.1"),
},
)
assert.Error(t, err, "expected error, got nil")
}
func TestValidateIpv4GatewayNotGlobalUnicast(t *testing.T) {
_, err := parseAndValidateStaticIPv4Config(
&IPv4StaticConfig{
Address: null.StringFrom("127.0.0.1"),
Netmask: null.StringFrom("255.255.255.0"),
Gateway: null.StringFrom("127.0.0.1"),
},
)
assert.Error(t, err, "expected error, got nil")
}
func TestValidateIpv4GatewayPointToPoint(t *testing.T) {
_, err := parseAndValidateStaticIPv4Config(
&IPv4StaticConfig{
Address: null.StringFrom("192.168.1.100"),
Netmask: null.StringFrom("255.255.255.255"),
Gateway: null.StringFrom("203.0.113.1"),
},
)
assert.NoError(t, err, "expected no error, got %v", err)
}
func TestValidateIpv4Config(t *testing.T) {
_, err := parseAndValidateStaticIPv4Config(
&IPv4StaticConfig{
Address: null.StringFrom("192.168.1.100"),
Netmask: null.StringFrom("255.255.255.0"),
Gateway: null.StringFrom("192.168.1.1"),
},
)
assert.NoError(t, err, "expected no error, got %v", err)
}
func TestValidateIpv6LinkLocalAddress(t *testing.T) {
_, err := parseAndValidateStaticIPv6Config(
&IPv6StaticConfig{
Address: null.StringFrom("fe80::114"),
PrefixLength: null.IntFrom(64),
Gateway: null.StringFrom("fe80::1"),
},
)
assert.Error(t, err, "expected error, got nil")
}
func TestValidateIpv6GatewaySameAsAddress(t *testing.T) {
_, err := parseAndValidateStaticIPv6Config(
&IPv6StaticConfig{
Address: null.StringFrom("2001:0db8::2"),
PrefixLength: null.IntFrom(64),
Gateway: null.StringFrom("2001:0db8::2"),
},
)
assert.Error(t, err, "expected error, got nil")
}
func TestValidateIpv6GatewayNotInPrefix(t *testing.T) {
_, err := parseAndValidateStaticIPv6Config(
&IPv6StaticConfig{
Address: null.StringFrom("2001:0db8::2"),
PrefixLength: null.IntFrom(64),
Gateway: null.StringFrom("2001:0db8:1::1"),
},
)
assert.Error(t, err, "expected error, got nil")
}
func TestValidateIpv6AddressInvalidPrefixLength(t *testing.T) {
_, err := parseAndValidateStaticIPv6Config(
&IPv6StaticConfig{
Address: null.StringFrom("2001:0db8::2"),
PrefixLength: null.IntFrom(1919),
Gateway: null.StringFrom("2001:0db8::1"),
},
)
assert.Error(t, err, "expected error, got nil")
}
func TestValidateIpv6GatewayLinkLocal(t *testing.T) {
_, err := parseAndValidateStaticIPv6Config(
&IPv6StaticConfig{
Address: null.StringFrom("2001:0db8::2"),
PrefixLength: null.IntFrom(64),
Gateway: null.StringFrom("fe80::1"),
},
)
assert.NoError(t, err, "expected no error, got %v", err)
}
func TestValidateIpv6Config(t *testing.T) {
_, err := parseAndValidateStaticIPv6Config(
&IPv6StaticConfig{
Address: null.StringFrom("2001:0db8::2"),
PrefixLength: null.IntFrom(64),
Gateway: null.StringFrom("2001:0db8::1"),
},
)
assert.NoError(t, err, "expected no error, got %v", err)
}

View File

@ -1,12 +0,0 @@
package udhcpc
func (u *DHCPClient) GetNtpServers() []string {
if u.lease == nil {
return nil
}
servers := make([]string, len(u.lease.NTPServers))
for i, server := range u.lease.NTPServers {
servers[i] = server.String()
}
return servers
}

View File

@ -1,74 +0,0 @@
package udhcpc
import (
"testing"
"time"
)
func TestUnmarshalDHCPCLease(t *testing.T) {
lease := &Lease{}
err := UnmarshalDHCPCLease(lease, `
# generated @ Mon Jan 4 19:31:53 UTC 2021
# 19:31:53 up 0 min, 0 users, load average: 0.72, 0.14, 0.04
# the date might be inaccurate if the clock is not set
ip=192.168.0.240
siaddr=192.168.0.1
sname=
boot_file=
subnet=255.255.255.0
timezone=
router=192.168.0.1
timesvr=
namesvr=
dns=172.19.53.2
logsvr=
cookiesvr=
lprsvr=
hostname=
bootsize=
domain=
swapsvr=
rootpath=
ipttl=
mtu=
broadcast=
ntpsrv=162.159.200.123
wins=
lease=172800
dhcptype=
serverid=192.168.0.1
message=
tftp=
bootfile=
`)
if lease.IPAddress.String() != "192.168.0.240" {
t.Fatalf("expected ip to be 192.168.0.240, got %s", lease.IPAddress.String())
}
if lease.Netmask.String() != "255.255.255.0" {
t.Fatalf("expected netmask to be 255.255.255.0, got %s", lease.Netmask.String())
}
if len(lease.Routers) != 1 {
t.Fatalf("expected 1 router, got %d", len(lease.Routers))
}
if lease.Routers[0].String() != "192.168.0.1" {
t.Fatalf("expected router to be 192.168.0.1, got %s", lease.Routers[0].String())
}
if len(lease.NTPServers) != 1 {
t.Fatalf("expected 1 timeserver, got %d", len(lease.NTPServers))
}
if lease.NTPServers[0].String() != "162.159.200.123" {
t.Fatalf("expected timeserver to be 162.159.200.123, got %s", lease.NTPServers[0].String())
}
if len(lease.DNS) != 1 {
t.Fatalf("expected 1 dns, got %d", len(lease.DNS))
}
if lease.DNS[0].String() != "172.19.53.2" {
t.Fatalf("expected dns to be 172.19.53.2, got %s", lease.DNS[0].String())
}
if lease.LeaseTime != 172800*time.Second {
t.Fatalf("expected lease time to be 172800 seconds, got %d", lease.LeaseTime)
}
if err != nil {
t.Fatal(err)
}
}

View File

@ -1,212 +0,0 @@
package udhcpc
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
func readFileNoStat(filename string) ([]byte, error) {
const maxBufferSize = 1024 * 1024
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
reader := io.LimitReader(f, maxBufferSize)
return io.ReadAll(reader)
}
func toCmdline(path string) ([]string, error) {
data, err := readFileNoStat(path)
if err != nil {
return nil, err
}
if len(data) < 1 {
return []string{}, nil
}
return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil
}
func (p *DHCPClient) findUdhcpcProcess() (int, error) {
// read procfs for udhcpc processes
// we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs
processes, err := os.ReadDir("/proc")
if err != nil {
return 0, err
}
// iterate over the processes
for _, d := range processes {
// check if file is numeric
pid, err := strconv.Atoi(d.Name())
if err != nil {
continue
}
// check if it's a directory
if !d.IsDir() {
continue
}
cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline"))
if err != nil {
continue
}
if len(cmdline) < 1 {
continue
}
if cmdline[0] != "udhcpc" {
continue
}
cmdlineText := strings.Join(cmdline, " ")
// check if it's a udhcpc process
if strings.Contains(cmdlineText, fmt.Sprintf("-i %s", p.InterfaceName)) {
p.logger.Debug().
Str("pid", d.Name()).
Interface("cmdline", cmdline).
Msg("found udhcpc process")
return pid, nil
}
}
return 0, errors.New("udhcpc process not found")
}
func (c *DHCPClient) getProcessPid() (int, error) {
var pid int
if c.pidFile != "" {
// try to read the pid file
pidHandle, err := os.ReadFile(c.pidFile)
if err != nil {
c.logger.Warn().Err(err).
Str("pidFile", c.pidFile).Msg("failed to read udhcpc pid file")
}
// if it exists, try to read the pid
if pidHandle != nil {
pidFromFile, err := strconv.Atoi(string(pidHandle))
if err != nil {
c.logger.Warn().Err(err).
Str("pidFile", c.pidFile).Msg("failed to convert pid file to int")
}
pid = pidFromFile
}
}
// if the pid is 0, try to find the pid using procfs
if pid == 0 {
newPid, err := c.findUdhcpcProcess()
if err != nil {
return 0, err
}
pid = newPid
}
return pid, nil
}
func (c *DHCPClient) getProcess() *os.Process {
pid, err := c.getProcessPid()
if err != nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
c.logger.Warn().Err(err).
Int("pid", pid).Msg("failed to find process")
return nil
}
return process
}
func (c *DHCPClient) GetProcess() *os.Process {
if c.process == nil {
process := c.getProcess()
if process == nil {
return nil
}
c.process = process
}
err := c.process.Signal(syscall.Signal(0))
if err != nil && errors.Is(err, os.ErrProcessDone) {
oldPid := c.process.Pid
c.process = nil
c.process = c.getProcess()
if c.process == nil {
c.logger.Error().Msg("failed to find new udhcpc process")
return nil
}
c.logger.Warn().
Int("oldPid", oldPid).
Int("newPid", c.process.Pid).
Msg("udhcpc process pid changed")
} else if err != nil {
c.logger.Warn().Err(err).
Int("pid", c.process.Pid).Msg("udhcpc process is not running")
}
return c.process
}
func (c *DHCPClient) KillProcess() error {
process := c.GetProcess()
if process == nil {
return nil
}
return process.Kill()
}
func (c *DHCPClient) ReleaseProcess() error {
process := c.GetProcess()
if process == nil {
return nil
}
return process.Release()
}
func (c *DHCPClient) signalProcess(sig syscall.Signal) error {
process := c.GetProcess()
if process == nil {
return nil
}
s := process.Signal(sig)
if s != nil {
c.logger.Warn().Err(s).
Int("pid", process.Pid).
Str("signal", sig.String()).
Msg("failed to signal udhcpc process")
return s
}
return nil
}
func (c *DHCPClient) Renew() error {
return c.signalProcess(syscall.SIGUSR1)
}
func (c *DHCPClient) Release() error {
return c.signalProcess(syscall.SIGUSR2)
}

View File

@ -1,198 +0,0 @@
package udhcpc
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"time"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog"
)
const (
DHCPLeaseFile = "/run/udhcpc.%s.info"
DHCPPidFile = "/run/udhcpc.%s.pid"
)
type DHCPClient struct {
InterfaceName string
leaseFile string
pidFile string
lease *Lease
logger *zerolog.Logger
process *os.Process
onLeaseChange func(lease *Lease)
}
type DHCPClientOptions struct {
InterfaceName string
PidFile string
Logger *zerolog.Logger
OnLeaseChange func(lease *Lease)
}
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
if options.Logger == nil {
options.Logger = &defaultLogger
}
l := options.Logger.With().Str("interface", options.InterfaceName).Logger()
return &DHCPClient{
InterfaceName: options.InterfaceName,
logger: &l,
leaseFile: fmt.Sprintf(DHCPLeaseFile, options.InterfaceName),
pidFile: options.PidFile,
onLeaseChange: options.OnLeaseChange,
}
}
func (c *DHCPClient) getWatchPaths() []string {
watchPaths := make(map[string]interface{})
watchPaths[filepath.Dir(c.leaseFile)] = nil
if c.pidFile != "" {
watchPaths[filepath.Dir(c.pidFile)] = nil
}
paths := make([]string, 0)
for path := range watchPaths {
paths = append(paths, path)
}
return paths
}
// Run starts the DHCP client and watches the lease file for changes.
// this isn't a blocking call, and the lease file is reloaded when a change is detected.
func (c *DHCPClient) Run() error {
err := c.loadLeaseFile()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer watcher.Close()
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
continue
}
if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
continue
}
if event.Name == c.leaseFile {
c.logger.Debug().
Str("event", event.Op.String()).
Str("path", event.Name).
Msg("udhcpc lease file updated, reloading lease")
_ = c.loadLeaseFile()
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
c.logger.Error().Err(err).Msg("error watching lease file")
}
}
}()
for _, path := range c.getWatchPaths() {
err = watcher.Add(path)
if err != nil {
c.logger.Error().
Err(err).
Str("path", path).
Msg("failed to watch directory")
return err
}
}
// TODO: update udhcpc pid file
// we'll comment this out for now because the pid might change
// process := c.GetProcess()
// if process == nil {
// c.logger.Error().Msg("udhcpc process not found")
// }
// block the goroutine until the lease file is updated
<-make(chan struct{})
return nil
}
func (c *DHCPClient) loadLeaseFile() error {
file, err := os.ReadFile(c.leaseFile)
if err != nil {
return err
}
data := string(file)
if data == "" {
c.logger.Debug().Msg("udhcpc lease file is empty")
return nil
}
lease := &Lease{}
err = UnmarshalDHCPCLease(lease, string(file))
if err != nil {
return err
}
isFirstLoad := c.lease == nil
// Skip processing if lease hasn't changed to avoid unnecessary wake-ups.
if reflect.DeepEqual(c.lease, lease) {
return nil
}
c.lease = lease
if lease.IPAddress == nil {
c.logger.Info().
Interface("lease", lease).
Str("data", string(file)).
Msg("udhcpc lease cleared")
return nil
}
msg := "udhcpc lease updated"
if isFirstLoad {
msg = "udhcpc lease loaded"
}
leaseExpiry, err := lease.SetLeaseExpiry()
if err != nil {
c.logger.Error().Err(err).Msg("failed to get dhcp lease expiry")
} else {
expiresIn := time.Until(leaseExpiry)
c.logger.Info().
Interface("expiry", leaseExpiry).
Str("expiresIn", expiresIn.String()).
Msg("current dhcp lease expiry time calculated")
}
c.onLeaseChange(lease)
c.logger.Info().
Str("ip", lease.IPAddress.String()).
Str("leaseTime", lease.LeaseTime.String()).
Interface("data", lease).
Msg(msg)
return nil
}
func (c *DHCPClient) GetLease() *Lease {
return c.lease
}

View File

@ -3,8 +3,8 @@ package kvm
import ( import (
"fmt" "fmt"
"github.com/jetkvm/kvm/internal/dhclient"
"github.com/jetkvm/kvm/internal/network" "github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/udhcpc"
) )
const ( const (
@ -53,7 +53,7 @@ func initNetwork() error {
OnInitialCheck: func(state *network.NetworkInterfaceState) { OnInitialCheck: func(state *network.NetworkInterfaceState) {
networkStateChanged() networkStateChanged()
}, },
OnDhcpLeaseChange: func(lease *udhcpc.Lease) { OnDhcpLeaseChange: func(lease *dhclient.Lease) {
networkStateChanged() networkStateChanged()
if currentSession == nil { if currentSession == nil {

16
ui/package-lock.json generated
View File

@ -28,6 +28,7 @@
"react": "^19.1.1", "react": "^19.1.1",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
@ -5786,6 +5787,21 @@
"react": "^19.1.1" "react": "^19.1.1"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.62.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-hot-toast": { "node_modules/react-hot-toast": {
"version": "2.5.2", "version": "2.5.2",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",

View File

@ -267,7 +267,7 @@ export default function SettingsNetworkRoute() {
placeholder="http://proxy.example.com:8080" placeholder="http://proxy.example.com:8080"
{...register("http_proxy", { {...register("http_proxy", {
validate: (value: string | null) => { validate: (value: string | null) => {
if (value === "") return true; if (value === "" || value === null) return true;
if (!validator.isURL(value || "", { protocols: ["http", "https"] })) { if (!validator.isURL(value || "", { protocols: ["http", "https"] })) {
return "Invalid HTTP proxy URL"; return "Invalid HTTP proxy URL";
} }