Compare commits

...

4 Commits

Author SHA1 Message Date
Stavros 35cd3b9ce5 refactor: simplify listener logic 2026-05-11 16:32:30 +03:00
Stavros 90145dd774 i18n: i18n tailscale frontend features 2026-05-11 16:13:03 +03:00
Stavros 9c0fe751cc refactor: use better names for context in login page 2026-05-11 15:52:17 +03:00
Stavros bc7c604a7d feat: add option to disable tailscale integration 2026-05-11 15:48:35 +03:00
8 changed files with 209 additions and 179 deletions
+13 -1
View File
@@ -80,5 +80,17 @@
"profileScopeDescription": "Allows the app to access your profile information.", "profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Groups", "groupsScopeName": "Groups",
"groupsScopeDescription": "Allows the app to access your group information.", "groupsScopeDescription": "Allows the app to access your group information.",
"backToLoginButton": "Back to login" "backToLoginButton": "Back to login",
"phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address.",
"loginTailscaleTitle": "Continue with Tailscale",
"loginTailscaleDescription": "We detected that you are accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleSubmit": "Continue with Tailscale",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
} }
+9 -1
View File
@@ -84,5 +84,13 @@
"phoneScopeName": "Phone", "phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.", "phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address", "addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address." "addressScopeDescription": "Allows the app to access your address.",
"loginTailscaleTitle": "Continue with Tailscale",
"loginTailscaleDescription": "We detected that you are accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleSubmit": "Continue with Tailscale",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
} }
+19 -17
View File
@@ -37,7 +37,11 @@ const iconMap: Record<string, React.ReactNode> = {
export const LoginPage = () => { export const LoginPage = () => {
const { auth, tailscale } = useUserContext(); const { auth, tailscale } = useUserContext();
const { ui, oauth, auth: cauth } = useAppContext(); const {
ui,
oauth,
auth: { providers },
} = useAppContext();
const { search } = useLocation(); const { search } = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -56,15 +60,15 @@ export const LoginPage = () => {
const oidcParams = useOIDCParams(searchParams); const oidcParams = useOIDCParams(searchParams);
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState( const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
cauth.providers.find((provider) => provider.id === oauth.autoRedirect) !== providers.find((provider) => provider.id === oauth.autoRedirect) !==
undefined && redirectUri !== undefined, undefined && redirectUri !== undefined,
); );
const oauthProviders = cauth.providers.filter( const oauthProviders = providers.filter(
(provider) => provider.id !== "local" && provider.id !== "ldap", (provider) => provider.id !== "local" && provider.id !== "ldap",
); );
const userAuthConfigured = const userAuthConfigured =
cauth.providers.find( providers.find(
(provider) => provider.id === "local" || provider.id === "ldap", (provider) => provider.id === "local" || provider.id === "ldap",
) !== undefined; ) !== undefined;
@@ -154,8 +158,8 @@ export const LoginPage = () => {
mutationFn: () => axios.post("/api/user/tailscale"), mutationFn: () => axios.post("/api/user/tailscale"),
mutationKey: ["tailscale"], mutationKey: ["tailscale"],
onSuccess: () => { onSuccess: () => {
toast.success("Logged in", { toast.success(t("loginSuccessTitle"), {
description: t("Tailscale session confirmed"), description: t("loginTailscaleSuccess"),
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
@@ -169,8 +173,8 @@ export const LoginPage = () => {
}, 500); }, 500);
}, },
onError: () => { onError: () => {
toast.error("Failed to login", { toast.error(t("loginFailTitle"), {
description: "Failed to authenticate with Tailscale.", description: t("loginTailscaleFail"),
}); });
}, },
}); });
@@ -262,17 +266,15 @@ export const LoginPage = () => {
<CardHeader className="gap-3"> <CardHeader className="gap-3">
<TailscaleIcon className="mx-auto h-8 w-8" /> <TailscaleIcon className="mx-auto h-8 w-8" />
<CardTitle className="text-center text-xl"> <CardTitle className="text-center text-xl">
Tinyauth · Tailscale {t("loginTailscaleTitle")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
We detected that you are accessing Tinyauth from an authorized {t("loginTailscaleDescription")}
Tailscale device. Would you like to continue with your Tailscale
credentials?
</div> </div>
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
Machine Name: <code>{tailscale.nodeName}</code> {t("loginTailscaleDeviceName")} <code>{tailscale.nodeName}</code>
</div> </div>
</CardContent> </CardContent>
<CardFooter className="flex flex-col items-stretch gap-3"> <CardFooter className="flex flex-col items-stretch gap-3">
@@ -281,7 +283,7 @@ export const LoginPage = () => {
onClick={() => tailscaleMutate()} onClick={() => tailscaleMutate()}
loading={tailscaleIsPending} loading={tailscaleIsPending}
> >
Continue with Tailscale {t("loginTailscaleSubmit")}
</Button> </Button>
<Button <Button
className="w-full" className="w-full"
@@ -289,7 +291,7 @@ export const LoginPage = () => {
onClick={() => setUseTailscale(false)} onClick={() => setUseTailscale(false)}
disabled={tailscaleIsPending} disabled={tailscaleIsPending}
> >
Use other login method {t("loginTailscaleOtherMethod")}
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
@@ -300,7 +302,7 @@ export const LoginPage = () => {
<Card> <Card>
<CardHeader className="gap-1.5"> <CardHeader className="gap-1.5">
<CardTitle className="text-center text-xl">{ui.title}</CardTitle> <CardTitle className="text-center text-xl">{ui.title}</CardTitle>
{cauth.providers.length > 0 && ( {providers.length > 0 && (
<CardDescription className="text-center"> <CardDescription className="text-center">
{oauthProviders.length !== 0 {oauthProviders.length !== 0
? t("loginTitle") ? t("loginTitle")
@@ -338,7 +340,7 @@ export const LoginPage = () => {
})()} })()}
/> />
)} )}
{cauth.providers.length == 0 && ( {providers.length == 0 && (
<pre className="break-normal! text-sm text-red-600"> <pre className="break-normal! text-sm text-red-600">
{t("failedToFetchProvidersTitle")} {t("failedToFetchProvidersTitle")}
</pre> </pre>
+11 -3
View File
@@ -75,9 +75,17 @@ export const LogoutPage = () => {
if (auth.providerId === "tailscale") { if (auth.providerId === "tailscale") {
return ( return (
<LogoutLayout logoutMutation={logoutMutation}> <LogoutLayout logoutMutation={logoutMutation}>
You are currently logged in with the Tailscale integration identified by <Trans
the <code>{tailscale.nodeName}</code> node. Click the button below to i18nKey="logoutTailscaleSubtitle"
log out. t={t}
components={{
code: <code />,
}}
values={{
deviceName: tailscale.nodeName,
}}
shouldUnescape={true}
/>
</LogoutLayout> </LogoutLayout>
); );
} }
+23 -156
View File
@@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -38,16 +37,17 @@ type Services struct {
} }
type BootstrapApp struct { type BootstrapApp struct {
config model.Config config model.Config
runtime model.RuntimeConfig runtime model.RuntimeConfig
services Services services Services
log *logger.Logger log *logger.Logger
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
queries *repository.Queries queries *repository.Queries
router *gin.Engine router *gin.Engine
db *sql.DB db *sql.DB
wg sync.WaitGroup wg sync.WaitGroup
listeners []Listener
} }
func NewBootstrapApp(config model.Config) *BootstrapApp { func NewBootstrapApp(config model.Config) *BootstrapApp {
@@ -254,56 +254,32 @@ func (app *BootstrapApp) Setup() error {
app.wg.Go(app.heartbeatRoutine) app.wg.Go(app.heartbeatRoutine)
} }
// create err channel to listen for server errors // setup listeners
errChanLen := 0
runUnix := app.config.Server.SocketPath != "" runUnix := app.config.Server.SocketPath != ""
runHTTP := app.config.Server.SocketPath == "" || app.config.Server.ConcurrentListenersEnabled runHTTP := app.config.Server.SocketPath == "" || app.config.Server.ConcurrentListenersEnabled
runTailscale := app.services.tailscaleService != nil runTailscale := app.services.tailscaleService != nil
if runUnix { if runHTTP {
errChanLen++ app.listeners = append(app.listeners, ListenerHTTP)
} }
if runHTTP { if runUnix {
errChanLen++ app.listeners = append(app.listeners, ListenerUnix)
} }
if runTailscale { if runTailscale {
errChanLen++ app.listeners = append(app.listeners, ListenerTailscale)
} }
errChan := make(chan error, errChanLen)
if app.config.Server.ConcurrentListenersEnabled { if app.config.Server.ConcurrentListenersEnabled {
app.log.App.Info().Msg("Concurrent listeners enabled, will run on all available listeners") app.log.App.Info().Msg("Concurrent listeners enabled, will run on all available listeners")
} }
// serve unix // run listeners
if runUnix { lec, err := app.runListeners()
app.wg.Go(func() {
if err := app.serveUnix(); err != nil {
errChan <- err
}
})
}
// serve to http if err != nil {
if runHTTP { return fmt.Errorf("failed to run listeners: %w", err)
app.wg.Go(func() {
if err := app.serveHTTP(); err != nil {
errChan <- err
}
})
}
// serve to tailscale
if runTailscale {
app.wg.Go(func() {
if err := app.serveTailscale(); err != nil {
errChan <- err
}
})
} }
// monitor cancellation and server errors // monitor cancellation and server errors
@@ -312,123 +288,14 @@ func (app *BootstrapApp) Setup() error {
case <-app.ctx.Done(): case <-app.ctx.Done():
app.log.App.Info().Msg("Oh, it's time for me to go, bye!") app.log.App.Info().Msg("Oh, it's time for me to go, bye!")
return nil return nil
case err := <-errChan: case err := <-lec:
if err != nil { if err != nil {
return fmt.Errorf("server error: %w", err) return fmt.Errorf("listener error: %w", err)
} }
} }
} }
} }
func (app *BootstrapApp) serveHTTP() error {
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
app.log.App.Info().Msgf("Starting server on %s", address)
server := &http.Server{
Addr: address,
Handler: app.router.Handler(),
}
go func() {
<-app.ctx.Done()
app.log.App.Debug().Msg("Shutting down http listener")
server.Shutdown(app.ctx)
}()
err := server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("failed to start http listener: %w", err)
}
return nil
}
func (app *BootstrapApp) serveUnix() error {
if app.config.Server.SocketPath == "" {
return nil
}
_, err := os.Stat(app.config.Server.SocketPath)
if err == nil {
app.log.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
err := os.Remove(app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to remove existing socket file: %w", err)
}
}
app.log.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
listener, err := net.Listen("unix", app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to create unix socket listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
shutdown := func() {
server.Shutdown(app.ctx)
listener.Close()
os.Remove(app.config.Server.SocketPath)
}
go func() {
<-app.ctx.Done()
app.log.App.Debug().Msg("Shutting down unix socket listener")
shutdown()
}()
err = server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
shutdown()
return fmt.Errorf("failed to start unix socket listener: %w", err)
}
return nil
}
func (app *BootstrapApp) serveTailscale() error {
app.log.App.Info().Msgf("Starting Tailscale server on %s", app.services.tailscaleService.GetHostname())
listener, err := app.services.tailscaleService.CreateListener()
if err != nil {
return fmt.Errorf("failed to create tailscale listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
shutdown := func() {
server.Shutdown(app.ctx)
listener.Close()
}
go func() {
<-app.ctx.Done()
app.log.App.Debug().Msg("Shutting down Tailscale listener")
shutdown()
}()
err = server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
shutdown()
return fmt.Errorf("failed to start tailscale listener: %w", err)
}
return nil
}
func (app *BootstrapApp) heartbeatRoutine() { func (app *BootstrapApp) heartbeatRoutine() {
ticker := time.NewTicker(time.Duration(12) * time.Hour) ticker := time.NewTicker(time.Duration(12) * time.Hour)
defer ticker.Stop() defer ticker.Stop()
+128
View File
@@ -1,7 +1,11 @@
package bootstrap package bootstrap
import ( import (
"errors"
"fmt" "fmt"
"net"
"net/http"
"os"
"github.com/tinyauthapp/tinyauth/internal/controller" "github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/middleware" "github.com/tinyauthapp/tinyauth/internal/middleware"
@@ -9,6 +13,14 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type Listener int
const (
ListenerHTTP Listener = iota
ListenerUnix
ListenerTailscale
)
func (app *BootstrapApp) setupRouter() error { func (app *BootstrapApp) setupRouter() error {
// we don't want gin debug mode // we don't want gin debug mode
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
@@ -53,3 +65,119 @@ func (app *BootstrapApp) setupRouter() error {
app.router = engine app.router = engine
return nil return nil
} }
func (app *BootstrapApp) runListeners() (chan error, error) {
// lec -> listener error channel
lec := make(chan error, len(app.listeners))
for _, listenerType := range app.listeners {
listenerFunc, err := app.listenerFromType(listenerType)
if err != nil {
return nil, fmt.Errorf("failed to get listener function: %w", err)
}
app.wg.Go(func() {
lec <- listenerFunc()
})
}
return lec, nil
}
func (app *BootstrapApp) listenerFromType(listenerType Listener) (func() error, error) {
switch listenerType {
case ListenerHTTP:
return app.serveHTTP, nil
case ListenerUnix:
return app.serveUnix, nil
case ListenerTailscale:
return app.serveTailscale, nil
default:
return nil, fmt.Errorf("invalid listener type: %d", listenerType)
}
}
func (app *BootstrapApp) serveHTTP() error {
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
app.log.App.Info().Msgf("Starting server on %s", address)
listener, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("failed to create tcp listener: %w", err)
}
server := &http.Server{
Addr: address,
Handler: app.router.Handler(),
}
return app.serve(listener, server, "http")
}
func (app *BootstrapApp) serveUnix() error {
_, err := os.Stat(app.config.Server.SocketPath)
if err == nil {
app.log.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
err := os.Remove(app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to remove existing socket file: %w", err)
}
}
app.log.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
listener, err := net.Listen("unix", app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to create unix socket listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
return app.serve(listener, server, "unix socket")
}
func (app *BootstrapApp) serveTailscale() error {
app.log.App.Info().Msgf("Starting Tailscale server on %s", fmt.Sprintf("https://%s", app.services.tailscaleService.GetHostname()))
listener, err := app.services.tailscaleService.CreateListener()
if err != nil {
return fmt.Errorf("failed to create tailscale listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
return app.serve(listener, server, "tailscale")
}
func (app *BootstrapApp) serve(listener net.Listener, server *http.Server, name string) error {
shutdown := func() {
server.Shutdown(app.ctx)
listener.Close()
}
go func() {
<-app.ctx.Done()
app.log.App.Debug().Msgf("Shutting down %s listener", name)
shutdown()
}()
err := server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
shutdown()
return fmt.Errorf("failed to start %s listener: %w", name, err)
}
return nil
}
+2 -1
View File
@@ -62,7 +62,7 @@ func NewDefaultConfiguration() *Config {
ConfigFile: "", ConfigFile: "",
}, },
Tailscale: TailscaleConfig{ Tailscale: TailscaleConfig{
Dir: "./state", Dir: "./tailscale_state",
}, },
LabelProvider: "auto", LabelProvider: "auto",
} }
@@ -206,6 +206,7 @@ type ExperimentalConfig struct {
} }
type TailscaleConfig struct { type TailscaleConfig struct {
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled"`
Dir string `description:"Tailscale state directory." yaml:"dir"` Dir string `description:"Tailscale state directory." yaml:"dir"`
Hostname string `description:"Tailscale hostname." yaml:"hostname"` Hostname string `description:"Tailscale hostname." yaml:"hostname"`
AuthKey string `description:"Tailscale auth key." yaml:"authKey"` AuthKey string `description:"Tailscale auth key." yaml:"authKey"`
+4
View File
@@ -27,6 +27,10 @@ type TailscaleService struct {
} }
func NewTailscaleService(log *logger.Logger, config model.Config, ctx context.Context, wg *sync.WaitGroup) (*TailscaleService, error) { func NewTailscaleService(log *logger.Logger, config model.Config, ctx context.Context, wg *sync.WaitGroup) (*TailscaleService, error) {
if !config.Tailscale.Enabled {
return nil, nil
}
srv := new(tsnet.Server) srv := new(tsnet.Server)
// node options // node options