Keep certificate-directory problems on the station window and leave a hand-edit untouched until Save.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-11 19:29:32 +02:00
co-authored by Cursor
parent 775d1b5c82
commit d21e7051d7
8 changed files with 165 additions and 40 deletions
+11 -16
View File
@@ -73,7 +73,7 @@ func loadConfig(path string) (configLoad, error) {
}
return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil
}
return parseConfig(path, body)
return parseConfig(path, body, true)
}
// ConfigForm is the eight fields the Config window edits.
@@ -104,7 +104,7 @@ func templateForm() (ConfigForm, error) {
return ConfigForm{CertificateDir: "./funknetz", LogFile: logFile}, nil
}
func parseConfig(path string, body []byte) (configLoad, error) {
func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) {
var shape fileShape
if err := jsonv2.Unmarshal(body, &shape); err != nil {
form, ferr := templateForm()
@@ -137,8 +137,10 @@ func parseConfig(path string, body []byte) (configLoad, error) {
if err != nil {
return configLoad{}, err
}
if err := rewriteConfig(path, shape); err != nil {
return configLoad{}, fmt.Errorf("the file could not be read: %w", err)
if rewrite {
if err := rewriteConfig(path, shape); err != nil {
return configLoad{}, fmt.Errorf("the file could not be read: %w", err)
}
}
}
cfg, err := parseOperational(shape)
@@ -149,30 +151,23 @@ func parseConfig(path string, body []byte) (configLoad, error) {
}
func finish(shape fileShape, cfg loaded) (configLoad, error) {
form, err := formFromShape(shape)
if err != nil {
return configLoad{}, err
}
form := formFromShape(shape)
if cfg.certFatal != "" {
return configLoad{cfg: cfg, form: form, detail: cfg.certFatal}, nil
return configLoad{cfg: cfg, form: form, loaded: true, detail: cfg.certFatal}, nil
}
return configLoad{cfg: cfg, form: form, loaded: true, detail: cfg.logNotice}, nil
}
func refused(shape fileShape, cause error) (configLoad, error) {
form, err := formFromShape(shape)
if err != nil {
return configLoad{}, err
}
return configLoad{form: form, detail: cause.Error()}, nil
return configLoad{form: formFromShape(shape), detail: cause.Error()}, nil
}
func formFromShape(shape fileShape) (ConfigForm, error) {
func formFromShape(shape fileShape) ConfigForm {
fallback, err := templateForm()
if err != nil {
fallback = ConfigForm{CertificateDir: "./funknetz"}
}
return mergeSalvage(shape, fallback), nil
return mergeSalvage(shape, fallback)
}
func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm {
+8 -2
View File
@@ -446,7 +446,10 @@ func TestInvalidSaveWritesNothingAndDoesNotConnect(t *testing.T) {
t.Cleanup(st.Close)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go func() { _ = st.Run(ctx) }()
go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
err = st.Save(station.ConfigForm{
Host: "",
@@ -496,7 +499,10 @@ func TestValidSaveCreatesFileAndConnects(t *testing.T) {
t.Cleanup(st.Close)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go func() { _ = st.Run(ctx) }()
go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
err = st.Save(station.ConfigForm{
Host: " 127.0.0.1 ",
+26 -12
View File
@@ -75,8 +75,19 @@ func (s *Station) Save(form ConfigForm) error {
s.logPath = next.logPath
s.logNotice = next.logNotice
reconnect := wasLoaded && connectionChanged(prev, next)
if reconnect || !wasLoaded {
s.fatal = ""
if next.certFatal != "" {
s.fatal = next.certFatal
s.closeLogLocked()
if next.enableLogging {
s.openLog()
}
if wasLoaded {
s.kickReconnect()
}
} else if reconnect || !wasLoaded {
if !wasLoaded || s.fatal == prev.certFatal {
s.fatal = ""
}
s.closeLogLocked()
if next.enableLogging {
s.openLog()
@@ -98,7 +109,8 @@ func (s *Station) kickReconnect() {
s.cancelDial()
}
if s.conn != nil {
s.conn.Close()
// Close unblocks the session read so Run can dial again.
_ = s.conn.Close()
}
select {
case s.apply <- struct{}{}:
@@ -139,7 +151,7 @@ func readForm(path string) (ConfigForm, string, bool) {
}
return form, fmt.Errorf("the file could not be read: %w", err).Error(), false
}
out, err := parseConfig(path, body)
out, err := parseConfig(path, body, false)
if err != nil {
return out.form, err.Error(), false
}
@@ -170,9 +182,6 @@ func validateForm(configPath string, form ConfigForm) (loaded, ConfigForm, error
return loaded{}, form, err
}
cfg = applyCertificate(cfg, shape, configPath)
if cfg.certFatal != "" {
return loaded{}, form, errors.New(cfg.certFatal)
}
cfg = applyLog(cfg, shape)
stored := ConfigForm{
Host: cfg.host,
@@ -272,11 +281,16 @@ func createExclusive(path string, body []byte) error {
}
_, werr := f.Write(body)
cerr := f.Close()
if werr != nil {
return werr
}
if cerr != nil {
if werr != nil || cerr != nil {
os.Remove(path)
if werr != nil {
return werr
}
return cerr
}
return os.Chmod(path, 0o600)
if err := os.Chmod(path, 0o600); err != nil {
os.Remove(path)
return err
}
return nil
}
+110 -4
View File
@@ -211,6 +211,107 @@ func TestConfigStaysClosedWhilePushToTalkIsHeld(t *testing.T) {
}
}
func TestCertificateProblemStaysOnTheStation(t *testing.T) {
path := filepath.Join(t.TempDir(), "funknetz.json")
body := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"certificateDir\":\"\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err)
}
st, err := station.New(station.Options{ConfigPath: path})
if err != nil {
t.Fatal(err)
}
t.Cleanup(st.Close)
got := st.State()
if got.ConfigOpen || got.NeedsConfig || got.Detail != "certificateDir is empty" || got.Connection != "Disconnected" {
t.Fatalf("State = %+v, want the station window with the certificate detail", got)
}
if st.Cancel() {
t.Fatal("Cancel() quit after a certificate-directory problem")
}
}
func TestOpenConfigDoesNotRewriteAHandEdit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "funknetz.json")
body := []byte(fullConfig("127.0.0.1", 5308, "DL1ABC", "key", 20, filepath.Join(dir, "funknetz.log")))
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err)
}
st, err := station.New(station.Options{ConfigPath: path})
if err != nil {
t.Fatal(err)
}
t.Cleanup(st.Close)
edited := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"" + filepath.Join(dir, "funknetz.log") + "\"}\n")
if err := os.WriteFile(path, edited, 0o600); err != nil {
t.Fatal(err)
}
st.OpenConfig()
if st.Cancel() {
t.Fatal("Cancel() quit")
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != string(edited) {
t.Fatalf("file = %s, want the hand-edit left unchanged", got)
}
if st.State().Callsign != "DL1ABC" || st.State().ConfigOpen {
t.Fatalf("State = %+v, want the loaded session and a closed Config window", st.State())
}
}
func TestSaveEmptyCertificateDirDoesNotConnect(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
path := filepath.Join(t.TempDir(), "funknetz.json")
st, err := station.New(station.Options{ConfigPath: path})
if err != nil {
t.Fatal(err)
}
t.Cleanup(st.Close)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
err = st.Save(station.ConfigForm{
Host: "127.0.0.1",
Port: port,
Callsign: "DL1ABC",
AuthKey: "key",
TalkGroup: 20,
CertificateDir: "",
LogFile: "/tmp/funknetz.log",
})
if err != nil {
t.Fatal(err)
}
got := st.State()
if got.ConfigOpen || got.Detail != "certificateDir is empty" || got.Connection != "Disconnected" {
t.Fatalf("State = %+v, want the window closed and the certificate detail", got)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("mode = %o, want 600", info.Mode().Perm())
}
ln.(*net.TCPListener).SetDeadline(time.Now().Add(200 * time.Millisecond))
if conn, err := ln.Accept(); err == nil {
conn.Close()
t.Fatal("opened a socket")
}
}
func TestLoggingOnlySaveKeepsTheSession(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
logPath := filepath.Join(t.TempDir(), "station.log")
@@ -253,7 +354,10 @@ func TestLoggingSaveDoesNotClearAFatalStop(t *testing.T) {
st.Fail("could not open the audio device")
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go func() { _ = st.Run(ctx) }()
go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
st.OpenConfig()
form := st.Config().Form
@@ -297,7 +401,10 @@ func TestSaveWriteFailureKeepsTheSession(t *testing.T) {
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
t.Cleanup(func() {
// Restore the directory so the test temp dir can be removed.
_ = os.Chmod(dir, 0o755)
})
in.st.OpenConfig()
form := in.st.Config().Form
form.TalkGroup = 21
@@ -393,8 +500,7 @@ func connStillOpen(conn net.Conn) error {
if err == nil {
return nil
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() {
return nil
}
return err
+2 -1
View File
@@ -62,7 +62,8 @@ func (s *Station) Run(ctx context.Context) error {
s.mu.Unlock()
if stale {
if conn != nil {
conn.Close()
// This dial used the config from before Save.
_ = conn.Close()
}
s.drainApply()
continue